SLOPSHOPPER

clawd

一只像素小螃蟹在 Claude 最新的回复上一行一行往下爬:思考时跑,调用工具时挖,出错时摔跤,做梦时睡觉。

newrowsguardcommandtoastprompt
v1.0.0MITupdated 2026-10-08mixmin0197/clawd-mods/plugins/clawd
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · clawd
› fix the failing auth test and add an audit log call ╭─────────╮ │ clawd │ ⏺ Read(src/auth.ts) │ 小螃蟹躲起来了 │ ⎿ Read 6 lines ╰─────────╯ ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /clawd ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

clawd-mods

两个 Claude Code mod:

  • dream:你睡着时,Claude 做梦。
  • clawd:一只像素小螃蟹,在 Claude 最新的回复上一行一行往下爬。

dream:Claude 做梦

  • 入睡:输入 /goodnight(/goodnight 0 立刻入梦),或者你空闲 2 小时后自动睡着。
  • 做梦:每晚 2~5 个梦,间隔 70~110 分钟。每个梦从这台电脑上所有项目的对话记录和记忆笔记里随机抽碎片, 有时混进以前的梦,再让模型自由联想。梦没有风格,可以莫名开始,也可以突然结束。
  • 醒来:你发第一条消息时,Claude 把昨晚的梦讲给你听。/dreams 打开梦境日记。
  • 不影响白天:做梦用的是不带工具、不带对话历史的单独调用;梦只存在 mod 自己的存储里, 讲梦是界面上的提示行,模型读不到。mod 不写任何文件,也不替你发消息。

要知道的:

  • 它会读取 ~/.claude/projects 下所有会话记录和记忆文件,抽出的片段会作为提示发给模型(用你自己的 Claude 账号)。
  • 夜里做梦需要 Claude Code 开着、电脑不休眠;没开着的话,醒来时会补做一个"残梦"。
  • 每个梦是一次模型调用(默认 sonnet),会消耗 token。

clawd:小螃蟹

  • 只在桌面版(desktop)显示,待在最新的一条回复上:在一行字上逐词跳,到行尾侧身爬到下一行,蛇形往下, 爬到最后一行再竖着爬回顶部。
  • 跟着 Claude 的状态变:思考时快跑,调用工具时挖土,出错时摔一跤,你 10 分钟不说话时打盹,dream 在做梦时睡觉。
  • 对脚下的字有反应:遇到"错误""失败"吓一跳,遇到"通过""完成"开心转身,在代码块上敲键盘。
  • 来了新回复,它从这条回复掉下去,再落到新回复上。

命令:

命令作用
/clawd让螃蟹躲起来 / 出来
/clawd debug显示校准框和尺子:每一行估算的位置一个框
/clawd width 700告诉它对话栏正文的实际宽度(看尺子);/clawd width 恢复自动

mod 拿不到界面上每个字的真实位置,螃蟹的落脚点是按排版估算的:字宽、行高、段距、列表缩进、 表格列宽、代码块和引用块的留白,都按桌面版的截图校准过,支持中英日韩混排。 窗口宽度变化后如果框对不上,用 /clawd debug 看尺子,再用 /clawd width 校正。

安装

需要支持函数 hook mod 的 Claude Code(2.1.288 或更新)。clawd 依赖 dream,两个一起装:

/plugin marketplace add mixmin0197/clawd-mods
/plugin install dream@clawd-mods
/plugin install clawd@clawd-mods

或者从克隆的仓库直接运行:

git clone https://github.com/mixmin0197/clawd-mods
claude --plugin-dir ./clawd-mods/plugins/dream --plugin-dir ./clawd-mods/plugins/clawd

开发

claude plugin validate plugins/clawd
claude plugin test plugins/clawd

License

MIT

Source 3 files
hooks/register.tsx 208 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import type { ClawdMode, ClawdSpot } from '../types'
4import { PX_PER_COLUMN, durationOf, positionAt, scene, type SceneArgs } from './sprite'
5
6type $ = EngineInterface
7
8const SECOND = 1000
9const MINUTE = 60 * SECOND
10const TRIP_MS = 2400
11const DOZE_MS = 10 * MINUTE // 你多久不说话它就打盹
12const WANDER_MS = [3, 5] as const // 每隔几秒看一眼:这一段爬完了就接着规划下一段
13const LEAVE_MS = 900
14
15// 画回复的钩子读这些;只有事件钩子写
16const spot = { plugin: 'clawd', key: 'spot' } as const
17const mood = { plugin: 'clawd', key: 'mode' } as const
18const hidden = { plugin: 'clawd', key: 'isHidden' } as const
19const debugging = { plugin: 'clawd', key: 'isDebug' } as const
20const widthRef = { plugin: 'clawd', key: 'width' } as const
21const dreaming = { plugin: 'dream', key: 'isAsleep' } as const
22
23// 事件钩子自己的计数,热重载清零也无妨
24let tools = 0
25let isWorking = false
26let tripUntil = 0
27let lastActive = 0
28const key = (text: string) => text.replace(/[^\p{L}\p{N}]/gu, '').slice(0, 24)
29
30const replies = async ($: $) => {
31  const history = await $.session.messages()
32  if ('deny' in history) return []
33
34  return history.filter(one => one.role === 'assistant' && key(one.text) !== '').map(one => ({ key: key(one.text), text: one.text }))
35}
36
37// 同一段文字、同一个状态,画面和推算用同一套参数
38const sceneArgs = (text: string, mode: ClawdMode, where: ClawdSpot, col?: number): SceneArgs => ({
39  mode,
40  col,
41  text,
42  seed: where.seed,
43  startX: where.startX,
44  startY: where.startY,
45  enter: where.enter,
46  exit: where.exit,
47})
48
49const moodNow = async ($: $): Promise<ClawdMode> => {
50  const now = await $.clock.now()
51  const { value: isDreaming = false } = await $.state.get(dreaming)
52  if (isDreaming) return 'sleep'
53  if (now < tripUntil) return 'trip'
54  if (tools > 0) return 'dig'
55  if (isWorking) return 'run'
56  if (lastActive > 0 && now - lastActive > DOZE_MS) return 'doze'
57
58  return 'roam'
59}
60
61// 这段路线走完了没有:没走完就不重新规划,让它一口气爬下去
62const isDone = (was: ClawdSpot, mode: ClawdMode, text: string, now: number, col?: number) =>
63  (now - was.since) / SECOND >= durationOf(sceneArgs(text, mode, was, col))
64
65// 落脚的回复:始终是最新那条。
66// 每次换画面都从螃蟹此刻的位置接着走,换回复时上下移动,不会凭空消失再出现
67const move = async ($: $, isWandering: boolean) => {
68  const now = await $.clock.now()
69  const mode = await moodNow($)
70  const all = await replies($)
71  const keys = all.map(one => one.key)
72  const { value: was } = await $.state.get(spot)
73  const { value: wasMode = 'roam' } = await $.state.get(mood)
74  // 用 /clawd width 设过宽度就按它推算,和画出来的一致
75  const { value: fixed = 0 } = await $.state.get(widthRef)
76  const col = fixed > 0 ? fixed : undefined
77  // 只待在最新那条回复上;来了新回复才搬过去
78  const target = keys.at(-1)
79  const room = keys.slice(-1)
80  if (target === undefined) return
81  const textOf = (k: string) => all.find(one => one.key === k)?.text ?? ''
82  // 螃蟹此刻在哪(按上一个画面的时间表推算)
83  const at = was ? positionAt(sceneArgs(textOf(was.target), wasMode, was, col), (now - was.since) / SECOND) : undefined
84  const seed = isWandering || !was ? Math.floor(Math.random() * 1000) : was.seed
85  const isRoomChanged = room.join('|') !== (was?.recent ?? []).join('|')
86
87  if (was && target !== was.target && keys.includes(was.target)) {
88    const isDown = keys.indexOf(target) > keys.indexOf(was.target)
89    await $.state.set(spot, { ...was, recent: room, since: now, startX: at?.x, startY: at?.y, enter: undefined, exit: isDown ? 'down' : 'up' })
90    await $.state.set(mood, 'leave')
91    await $.clock.sleep(LEAVE_MS)
92    await $.state.set(spot, { target, seed, recent: room, since: now + LEAVE_MS, startX: at?.x, enter: isDown ? 'above' : 'below' })
93    await $.state.set(mood, mode)
94  } else if (!was || target !== was.target || mode !== wasMode || isRoomChanged || (isWandering && isDone(was, wasMode, textOf(was.target), now, col))) {
95    const next: ClawdSpot = { target, seed, recent: room, since: now, startX: at?.x, startY: at?.y, enter: was ? undefined : 'above' }
96    await $.state.set(spot, next)
97    if (mode !== wasMode) await $.state.set(mood, mode)
98  }
99}
100
101const scheduleWander = ($: $) => {
102  const [min, max] = WANDER_MS
103  $.clock.after((min + Math.random() * (max - min)) * SECOND, () => {
104    void move($, true)
105      .catch(() => undefined)
106      .finally(() => scheduleWander($))
107  })
108}
109
110export const register: Register = on => {
111  on('session.start', async ($, e, next) => {
112    const started = await next(e)
113    lastActive = await $.clock.now()
114    await $.command.register({ name: 'clawd', description: '让小螃蟹出来或者躲起来;/clawd debug 校准框和尺子;/clawd width 630 设置对话栏宽度', argumentHint: '[debug | width 像素]' })
115    await move($, false)
116    scheduleWander($)
117
118    return started
119  })
120
121  on('command.run', { command: 'clawd' }, async ($, e) => {
122    // /clawd width 630:告诉它对话栏正文实际多宽(看校准尺子上文字右边缘对着的数字);/clawd width 不带数字就恢复自动
123    const width = e.args.trim().match(/^width(?:\s+(\d+))?$/)
124    if (width) {
125      const px = width[1] ? Number(width[1]) : 0
126      await $.state.set(widthRef, px)
127      $.ui.toast(px > 0 ? `对话栏宽度设为 ${px}px` : '对话栏宽度恢复自动')
128
129      return {}
130    }
131    // /clawd debug:在每一行估算的字顶画红线,截图校准用
132    if (e.args.trim() === 'debug') {
133      const { value: isDebug = false } = await $.state.get(debugging)
134      await $.state.set(debugging, !isDebug)
135      $.ui.toast(isDebug ? '校准线关了' : '校准线开了:红线是估算的字顶')
136
137      return {}
138    }
139    const { value: isHidden = false } = await $.state.get(hidden)
140    await $.state.set(hidden, !isHidden)
141    $.ui.toast(isHidden ? '小螃蟹出来了' : '小螃蟹躲起来了')
142
143    return {}
144  })
145
146  on('prompt.submit', async ($, e, next) => {
147    lastActive = await $.clock.now()
148    isWorking = true
149    await move($, false)
150
151    return next(e)
152  })
153
154  on('tool.call', async ($, e, next) => {
155    tools += 1
156    await move($, false)
157    try {
158      const ran = await next(e)
159      if (ran.isError) {
160        tripUntil = (await $.clock.now()) + TRIP_MS
161        $.clock.after(TRIP_MS, () => void move($, false))
162      }
163
164      return ran
165    } finally {
166      tools -= 1
167      await move($, false)
168    }
169  })
170
171  on('turn.complete', async ($, e, next) => {
172    const done = await next(e)
173    lastActive = await $.clock.now()
174    isWorking = false
175    await move($, false)
176
177    return done
178  })
179
180  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
181    const drawn = await next(e)
182    if (e.surface !== 'desktop') return drawn
183    const { value: isHidden = false } = await $.state.get(hidden)
184    const { value: where } = await $.state.get(spot)
185    const mine = key(e.props.text)
186    const isHere = !isHidden && where !== undefined && mine !== '' && where.target.startsWith(mine.slice(0, 12)) && mine.startsWith(where.target.slice(0, 12))
187    if (!isHere || !where) return drawn
188    const { value: mode = 'roam' } = await $.state.get(mood)
189    const { value: isDebug = false } = await $.state.get(debugging)
190    // 桌面版报告的列数换算成正文宽:窗口变宽变窄时折行跟着变
191    const { value: fixed = 0 } = await $.state.get(widthRef)
192    const col = fixed > 0 ? fixed : e.viewport?.columns ? e.viewport.columns * PX_PER_COLUMN : undefined
193    const source = scene({ ...sceneArgs(e.props.text, mode, where, col), isDebug })
194    const { Box, Svg } = $.ui.resolve(e)
195
196    // 盖在整条回复上,不占位置;字被盖住也没关系
197    return (
198      <Box key="clawd-host">
199        {drawn}
200        <Box position="absolute" top={0} left={0}>
201          {/* 不给宽高:给了的话桌面版会按消息宽度把整张图放大(约 3.6%),越往下偏得越多 */}
202          <Svg source={source} alt="小螃蟹" />
203        </Box>
204      </Box>
205    )
206  })
207}
208
hooks/sprite.ts 716 lines
1// 小螃蟹的像素画和动画。一张透明 SVG 盖在整条回复上(对话栏整个宽度),螃蟹只在字上活动,
2// 字被盖住也没关系。
3// 动画全是 CSS,桌面版自己播放,不需要 mod 一帧一帧重绘。
4// 回复上沿以外会被裁掉,所以第一行只能当“天花板”,螃蟹从第二行开始站。
5
6import type { ClawdMode } from '../types'
7
8export type Mode = ClawdMode
9
10const COLOR = '#D97757'
11const EYE = '#1a1a1a'
12const PX = 3 // 一个像素格 = 3px,整只螃蟹 36×24
13export const CRAB_W = 12 * PX
14export const CRAB_H = 8 * PX
15
16const rect = (x: number, y: number, w: number, h: number, fill = COLOR) =>
17  `<rect x="${x * PX}" y="${y * PX}" width="${w * PX}" height="${h * PX}" fill="${fill}"/>`
18
19// 照着图画的 12×8 网格:身体 2–9 列,左右钳子各 2 列(第 2、3 行),四条腿;钳子和眼睛能单独动
20const crab = (isClosed: boolean) =>
21  [
22    `<g class="arm-l">${rect(0, 2, 2, 2)}</g>`,
23    `<g class="arm-r">${rect(10, 2, 2, 2)}</g>`,
24    rect(2, 0, 8, 6),
25    `<g class="legs-a">${rect(2, 6, 1, 2)}${rect(7, 6, 1, 2)}</g>`,
26    `<g class="legs-b">${rect(4, 6, 1, 2)}${rect(9, 6, 1, 2)}</g>`,
27    isClosed
28      ? `${rect(3, 1.5, 1, 0.34, EYE)}${rect(8, 1.5, 1, 0.34, EYE)}`
29      : `<g class="eyes"><g class="blink">${rect(3, 1, 1, 1, EYE)}${rect(8, 1, 1, 1, EYE)}</g></g>`,
30  ].join('')
31
32const BASE_CSS = `
33  g, rect, text, circle, ellipse { transform-box: fill-box; }
34  .blink { transform-origin: center; animation: blink 4.2s infinite; }
35  @keyframes blink { 0%, 94%, 100% { transform: scaleY(1); } 97% { transform: scaleY(0.15); } }
36  .legs-a, .legs-b { animation: step var(--step, 0.32s) steps(1) infinite; }
37  .legs-b { animation-delay: calc(var(--step, 0.32s) / 2); }
38  @keyframes step { 0% { transform: translateY(0); } 50% { transform: translateY(-${PX}px); } }
39  .still .legs-a, .still .legs-b { animation: none; }
40  .bob { transform-origin: bottom center; }
41`
42
43// ---- 估计的排版:桌面版不告诉 mod 每行在哪,按截图量出来的尺寸推算 ----
44
45export const COL_W = 697 // 对话栏正文宽(CSS px):按校准截图量的;桌面版给了列数时按列数算
46export const PX_PER_COLUMN = 697 / 106 // 截图那次桌面版报告 106 列,正文宽 697px
47const LINE = 20.3 // 正文行高
48const GAP = 11 // 段落之间多出来的距离
49const GLYPH_TOP = 3 // 字顶在行顶往下约 3px(校准截图里正文每一行都对得上)
50const CJK = 14
51const LATIN = 6.7
52
53// height:这一行估计占多高(画校准框用)
54// parts:表格一行里的每一格(各自的起止和文字);普通行没有
55export type Line = { top: number; x0: number; x1: number; text: string; isCode: boolean; height: number; parts?: Array<{ x0: number; x1: number; text: string }> }
56
57// 全角宽度:中日文字和标点、全角符号,以及韩文
58const isWide = (char: string) => /[\u2E80-\u9FFF\uFF00-\uFFEF\u3000-\u303F\uAC00-\uD7AF\u1100-\u11FF]/.test(char)
59const widthOf = (char: string, isMono: boolean) => (isMono ? 7.8 : isWide(char) ? CJK : LATIN)
60
61// 去掉 markdown 记号;紧挨着中日韩文字的引号会被显示成弯引号,在中文字体里是全角宽度,换成全角引号来算宽度
62const inline = (text: string) =>
63  text
64    .replace(/\*\*|__|`|~~|\[|\]\([^)]*\)/g, '')
65    .replace(/(?<=[\u2E80-\u9FFF\uAC00-\uD7AF])["“”'‘’]|["“”'‘’](?=[\u2E80-\u9FFF\uAC00-\uD7AF])/g, '"')
66
67// 按宽度把一段文字折成几行
68const NO_LINE_START = /[,。、;:!?)」』】》〉…—,.;:!?)\]]/
69
70const wrap = (text: string, room: number, scale: number) => {
71  const rows: Array<{ text: string; width: number }> = []
72  let row = ''
73  let width = 0
74  for (const char of text) {
75    const w = widthOf(char, false) * scale
76    if (width + w > room && row !== '') {
77      // 中文排版:句号、逗号这类标点不能在行首,把上一行的最后一个字一起带下来
78      const carry = NO_LINE_START.test(char) && [...row].length > 1 ? [...row].at(-1)! : ''
79      const kept = carry ? row.slice(0, row.length - carry.length) : row
80      const carried = carry ? widthOf(carry, false) * scale : 0
81      rows.push({ text: kept, width: width - carried })
82      row = carry
83      width = carried
84    }
85    row += char
86    width += w
87  }
88  if (row.trim() !== '') rows.push({ text: row, width })
89
90  return rows
91}
92
93// 量尺寸时用的截图缩放和实际的截图缩放
94const MEASURED = 1.33
95const ACTUAL = 1.36
96
97// 各种块之间的距离,都是按 /clawd debug 的校准截图量的
98const LIST_END = 18 // 列表结束后(下一块,或者空行隔开的另一个列表)多出来的距离
99const HEADING_BEFORE = 19
100const HEADING_LINE = 19.6 // 标题一行占的高度(后面接正文时再加 GAP)
101const ROW = 34 // 表格一行
102const ROW_TEXT = 7.4 // 表格行里字顶相对行顶
103const TABLE_END = 23 // 表格结束后到下一块的距离(校准框截图量的:比普通段距多约 12px)
104const CODE_LINE = 17.3
105const CODE_TOP = 4 // 代码块上面的留白(表格后面的距离单独算了之后,这里只剩代码框自己的内边距)
106const CODE_BOTTOM = 10
107const QUOTE_INDENT = 22 // 引用块:左边有一条竖线,字往右缩进约 22px
108
109const CELL_PAD = 24 // 表格每一格左右的留白
110const TABLE_WIDER = 1.08 // 表格比正文宽一些(几张校准截图拟合的)
111
112const cellsOf = (raw: string) => raw.replace(/^\s*\||\|\s*$/g, '').split('|').map(cell => inline(cell).trim())
113const isRow = (raw: string) => /^\s*\|/.test(raw)
114const isRule = (raw: string) => /^\s*\|?\s*:?-{2,}/.test(raw)
115const textWidth = (text: string) => [...text].reduce((sum, char) => sum + widthOf(char, false), 0)
116// 表格列宽(按校准截图拟合的):每列先按内容宽,但至少 MIN_COLUMN;
117// 放得下就把多出来的按比例分给各列;放不下就从比最小宽度宽的那些列里按超出的部分比例缩。
118// 返回每张表每一行(按原文行号)对应的列宽
119const MIN_COLUMN = 92
120const tableColumns = (raws: readonly string[], room: number) => {
121  const out = new Map<number, number[]>()
122  for (let i = 0; i < raws.length; i++) {
123    if (!isRow(raws[i]!) || (i > 0 && isRow(raws[i - 1]!))) continue
124    const indexes: number[] = []
125    for (let j = i; j < raws.length && isRow(raws[j]!); j++) if (!isRule(raws[j]!)) indexes.push(j)
126    const rows = indexes.map(j => cellsOf(raws[j]!))
127    const n = Math.max(1, ...rows.map(row => row.length))
128    const wants = Array.from({ length: n }, (_, c) => Math.max(MIN_COLUMN, Math.max(0, ...rows.map(row => textWidth(row[c] ?? ''))) + CELL_PAD))
129    const sum = (list: number[]) => list.reduce((a, b) => a + b, 0)
130    let widths: number[]
131    if (sum(wants) <= room) {
132      widths = wants.map(w => w + ((room - sum(wants)) * w) / sum(wants))
133    } else {
134      const over = wants.map(w => w - MIN_COLUMN)
135      const cut = Math.min(sum(over), sum(wants) - room)
136      widths = wants.map((w, c) => w - (cut * over[c]!) / (sum(over) || 1))
137    }
138    for (const j of indexes) out.set(j, widths)
139  }
140  return out
141}
142
143export const layout = (markdown: string, col = COL_W) => {
144  // 下面的尺寸是按量的时候的比例算的,宽度也换算过去
145  const room = col / (MEASURED / ACTUAL)
146  const lines: Line[] = []
147  let y = 0
148  let last: 'none' | 'p' | 'h' | 'li' | 'row' | 'code' = 'none'
149  let isBlank = false
150  let isCode = false
151  // 新的一块开始前加上和上一块之间的距离
152  const before = (kind: typeof last) => {
153    if (last === 'none') {
154      last = kind
155      isBlank = false
156      return
157    }
158    const isSameRun = kind === last && !isBlank && (kind === 'li' || kind === 'row' || kind === 'p')
159    if (!isSameRun) {
160      if (last === 'li') y += LIST_END
161      else if (last === 'row') y += Math.max(TABLE_END, kind === 'h' ? HEADING_BEFORE : 0) // 表格框下面还有留白
162      else if (kind === 'h') y += HEADING_BEFORE
163      else y += GAP
164    }
165    last = kind
166    isBlank = false
167  }
168
169  const raws = markdown.split('\n')
170  const tables = tableColumns(raws, room * TABLE_WIDER)
171  for (const [index, raw] of raws.entries()) {
172    if (/^\s*```/.test(raw)) {
173      if (!isCode) {
174        before('code')
175        y += CODE_TOP
176      } else {
177        y += CODE_BOTTOM
178        last = 'code'
179      }
180      isCode = !isCode
181      continue
182    }
183    if (isCode) {
184      const text = raw.replace(/\t/g, '  ')
185      lines.push({ top: y, x0: 12, x1: Math.min(room, 12 + [...text].length * 7.8), text, isCode: true, height: CODE_LINE })
186      y += CODE_LINE
187      continue
188    }
189    if (raw.trim() === '') {
190      isBlank = true
191      continue
192    }
193    const heading = raw.match(/^(#{1,6})\s+(.*)$/)
194    const item = raw.match(/^(\s*)([-*+]|\d+\.)\s+(.*)$/)
195    if (/^\s*\|/.test(raw)) {
196      if (/^\s*\|?\s*:?-{2,}/.test(raw)) continue
197      before('row')
198      // 每一格按它那一列的宽度折行(列宽像浏览器那样按内容分);一行有多高看最高的那一格
199      const cells = cellsOf(raw)
200      const widths = tables.get(index) ?? cells.map(() => (room * TABLE_WIDER) / Math.max(1, cells.length))
201      const wrapped = cells.map((cell, c) => wrap(cell, (widths[c] ?? room) - CELL_PAD, 1))
202      const tall = Math.max(1, ...wrapped.map(rows => rows.length))
203      // 格子里折出来的每一行字都算一行;每一行里各格的字是这一行的几段
204      const lefts = widths.map((_, c) => widths.slice(0, c).reduce((a, b) => a + b, 0) + CELL_PAD / 2)
205      for (let k = 0; k < tall; k++) {
206        const parts = wrapped.flatMap((rows, c) => (rows[k] ? [{ x0: lefts[c]!, x1: lefts[c]! + rows[k]!.width, text: rows[k]!.text }] : []))
207        if (parts.length === 0) continue
208        lines.push({
209          top: y + ROW_TEXT + k * LINE,
210          x0: parts[0]!.x0,
211          x1: parts.at(-1)!.x1,
212          text: parts.map(part => part.text).join(' '),
213          isCode: false,
214          height: LINE,
215          parts,
216        })
217      }
218      y += ROW - LINE + tall * LINE
219      continue
220    }
221    if (heading) {
222      before('h')
223      for (const row of wrap(inline(heading[2]!), room, 1.15)) {
224        lines.push({ top: y, x0: 0, x1: row.width, text: row.text, isCode: false, height: HEADING_LINE + 4 })
225        y += HEADING_LINE
226      }
227      continue
228    }
229    if (item) {
230      before('li')
231      const indent = 32 + Math.floor(item[1]!.length / 2) * 36 // 截图量的:一级列表 32px,每嵌套一层再缩进约 36px
232      for (const row of wrap(inline(item[3]!), room - indent, 1)) {
233        lines.push({ top: y, x0: indent, x1: indent + row.width, text: row.text, isCode: false, height: LINE })
234        y += LINE
235      }
236      y += 3.7
237      continue
238    }
239    before('p')
240    const quote = raw.match(/^\s*>\s?(.*)$/)
241    const indent = quote ? QUOTE_INDENT : 0
242    for (const row of wrap(inline(quote ? quote[1]! : raw), room - indent, 1)) {
243      lines.push({ top: y, x0: indent, x1: indent + row.width, text: row.text, isCode: false, height: LINE })
244      y += LINE
245    }
246  }
247
248  // 上面的尺寸都是按截图量的,量的时候把截图当成实际尺寸的 1.33 倍,其实是 1.36 倍
249  // (窗口 1470pt 宽、截图 2000px),所以统一缩小回来,不然越往下偏得越多
250  const k = MEASURED / ACTUAL
251
252  return {
253    lines: lines.map(one => ({
254      ...one,
255      top: one.top * k,
256      x0: one.x0 * k,
257      x1: one.x1 * k,
258      height: one.height * k,
259      parts: one.parts?.map(part => ({ ...part, x0: part.x0 * k, x1: part.x1 * k })),
260    })),
261    height: Math.max(LINE, y) * k,
262  }
263}
264
265// ---- 落脚点:在第二行起的每一行上,按词挑几个点 ----
266
267// edge:这一行字从哪里开始,螃蟹贴边爬时走这里,不会走到左边的空白里
268export type Stop = { x: number; y: number; word: string; isCode: boolean; line: number; edge: number }
269
270const MAX_LINES = 400 // 校准之后整条回复都对得上,基本不设限
271const CHUNK = 12 // 一段动画最多往下爬这么多行(太长会超过桌面版对一张图的大小限制),爬完下一段接着爬
272const PER_LINE = 3
273
274// 螃蟹站在这一行字顶上时它的上沿
275const standOn = (line: Line) => line.top + GLYPH_TOP - CRAB_H
276
277const wordsOf = (line: Line): Array<{ x: number; word: string }> => {
278  if (line.parts) return line.parts.flatMap(part => wordsOf({ ...line, x0: part.x0, x1: part.x1, text: part.text, parts: undefined }))
279  const words: Array<{ x: number; word: string }> = []
280  let x = line.x0
281  let start = x
282  let word = ''
283  let run = 0
284  const close = (end: number) => {
285    if (word.trim() !== '') words.push({ x: (start + end) / 2, word })
286    word = ''
287    run = 0
288  }
289  for (const char of line.text) {
290    const w = widthOf(char, line.isCode)
291    if (/[\s,。、;:!?,.;:!?()()]/.test(char)) {
292      close(x)
293      start = x + w
294    } else {
295      word += char
296      if (isWide(char) && ++run >= 3) {
297        close(x + w)
298        start = x + w
299      }
300    }
301    x += w
302  }
303  close(x)
304
305  return words
306}
307
308export const stopsOf = (markdown: string, col = COL_W) => {
309  const { lines, height } = layout(markdown, col)
310  // 站上去整只螃蟹都要在回复框里(上沿以外会被裁掉),所以开头几行可能站不了;
311  // 一行都站不了时,就踩着最后一行的底
312  const fits = lines.filter(one => standOn(one) >= -2).slice(0, MAX_LINES)
313  const floors = fits.length > 0 ? fits : lines.slice(-1).map(one => ({ ...one, top: one.top + LINE }))
314  const stops: Stop[] = []
315  floors.forEach((line, i) => {
316    const words = wordsOf(line)
317    const step = Math.max(1, Math.ceil(words.length / PER_LINE))
318    const picked = words.filter((_, k) => k % step === 0)
319    if (picked.length === 0) picked.push({ x: line.x0 + 20, word: '' })
320    const ordered = i % 2 === 0 ? picked : [...picked].reverse() // 蛇形:一行往右,下一行往左
321    const edge = Math.round(line.x0 + (line.isCode ? 0 : 2))
322    const right = Math.max(edge, Math.min(col - CRAB_W, line.x1 - CRAB_W))
323    for (const one of ordered) {
324      stops.push({
325        x: Math.round(Math.max(edge, Math.min(right, one.x - CRAB_W / 2))),
326        edge,
327        y: Math.round(standOn(line)),
328        word: one.word,
329        isCode: line.isCode,
330        line: i,
331      })
332    }
333  })
334  if (stops.length === 0) stops.push({ x: 0, y: Math.round(LINE + GLYPH_TOP - CRAB_H), word: '', isCode: false, line: 0, edge: 0 })
335
336  return { stops, height, lines: floors }
337}
338
339// ---- 小动作和对内容的反应 ----
340
341type Action = 'none' | 'look' | 'wave' | 'sit' | 'stomp' | 'scared' | 'happy' | 'typing'
342
343const SECONDS: Record<Action, number> = { none: 0.3, look: 1.6, wave: 1.4, sit: 2.2, stomp: 1, scared: 1.4, happy: 1.3, typing: 2 }
344
345export const reaction = (word: string, isCode: boolean): Action | undefined => {
346  if (/错误|报错|失败|异常|bug|error|fail|crash|崩溃|不行|没有/i.test(word)) return 'scared'
347  if (/✓|✔|通过|成功|好了|完成|搞定|done|pass|ok/i.test(word)) return 'happy'
348  if (isCode) return 'typing'
349
350  return undefined
351}
352
353// 可复现的随机数,同一个 seed 画出同一段表演
354const random = (seed: number) => {
355  let t = seed >>> 0
356
357  return () => {
358    t = (t + 0x6d2b79f5) >>> 0
359    let r = Math.imul(t ^ (t >>> 15), 1 | t)
360    r ^= r + Math.imul(r ^ (r >>> 7), 61 | r)
361
362    return ((r ^ (r >>> 14)) >>> 0) / 4294967296
363  }
364}
365
366type Track = Array<[number, string]>
367
368const keyframes = (name: string, track: Track) => {
369  const sorted = [...track].sort((a, b) => a[0] - b[0])
370
371  return `@keyframes ${name} { ${sorted.map(([pct, value]) => `${Math.min(100, Math.max(0, pct)).toFixed(3)}% { transform: ${value}; }`).join(' ')} }`
372}
373
374// ---- 一段表演 ----
375
376export type SceneArgs = {
377  mode: Mode
378  // 对话栏正文宽(CSS px),不给就用 COL_W
379  col?: number
380  // 校准:在每一行估算的“字顶”画一条细红线,标上行号
381  isDebug?: boolean
382  text: string
383  seed: number
384  // 从哪里接着走(上一个画面里螃蟹此刻的位置)
385  startX?: number
386  startY?: number
387  // 从别的回复过来:从上面掉进来,或者从下面跳上来
388  enter?: 'above' | 'below'
389  // 离开这条回复:从边缘跳下去,或者沿左边缘爬出上沿
390  exit?: 'down' | 'up'
391}
392
393type Point = { x: number; y: number }
394// turn:爬的时候身子怎么转(脚冲着字的那一边)
395type Beat = { from: Point; to: Point; move: number; kind: 'hop' | 'drop' | 'climb' | 'walk'; action: Action; stay: number; turn?: string }
396
397export const sceneSize = (text: string, col = COL_W) => ({ width: Math.ceil(col), height: Math.ceil(layout(text, col).height + CRAB_H * 2) })
398
399// 路线:只在字上。在一行上从一个词跳到下一个词,行尾跳到下一行的字上,
400// 最后一行之后沿着各行字的开头爬回第一行。画面虽然盖着整个对话栏宽度,螃蟹不在空白里停留
401type Floor = { y: number; x0: number; x1: number; words: Array<{ x: number; word: string }>; isCode: boolean; isGround: boolean }
402
403const floorsOf = (markdown: string, col = COL_W) => {
404  const { stops, height, lines } = stopsOf(markdown, col)
405  const right = col - CRAB_W
406  const floors: Floor[] = lines.map((line, i) => {
407    const x0 = Math.round(Math.max(0, line.x0 + (line.isCode ? 0 : 2)))
408    const x1 = Math.round(Math.max(x0, Math.min(right, line.x1 - CRAB_W)))
409
410    return {
411      y: Math.round(standOn(line)),
412      x0,
413      x1,
414      words: stops.filter(one => one.line === i).map(one => ({ x: one.x, word: one.word })).sort((a, b) => a.x - b.x),
415      isCode: line.isCode,
416      isGround: false,
417    }
418  })
419  // 回复底部当地面:整个对话栏宽度都能走
420  const groundY = Math.round(Math.max(height, (floors.at(-1)?.y ?? 0) + CRAB_H + LINE) - CRAB_H)
421  floors.push({ y: groundY, x0: 0, x1: right, words: [], isCode: false, isGround: true })
422
423  return { floors, height }
424}
425
426const plan = (args: SceneArgs) => {
427  const col = args.col ?? COL_W
428  const { floors, height } = floorsOf(args.text, col)
429  const rand = random(args.seed)
430  const isRun = args.mode === 'run'
431  // 主要是往下爬,小动作少一点
432  const idle: Action[] = ['none', 'none', 'none', 'none', 'none', 'none', 'look', 'wave', 'stomp']
433  const right = col - CRAB_W
434  const has = (v: number | undefined): v is number => v !== undefined && Number.isFinite(v)
435  const top = floors[0]!
436  const begin: Point =
437    args.enter === 'above' ? { x: has(args.startX) ? args.startX : top.x0, y: -CRAB_H - 8 }
438    : args.enter === 'below' ? { x: has(args.startX) ? args.startX : right, y: height + CRAB_H }
439    : has(args.startX) && has(args.startY) ? { x: args.startX, y: args.startY }
440    : { x: top.words[0]?.x ?? top.x0, y: top.y }
441  const beats: Beat[] = []
442  let at = begin
443  const go = (to: Point, kind: Beat['kind'], word = '', isCode = false, isQuiet = false, turn?: string) => {
444    const distance = Math.hypot(to.x - at.x, to.y - at.y)
445    if (distance < 1 && beats.length > 0) return
446    const move = kind === 'climb' ? distance / 70 : kind === 'walk' ? distance / (isRun ? 160 : 55) : Math.min(1.1, 0.3 + distance / (isRun ? 900 : 500))
447    const action: Action = isQuiet || isRun ? 'none' : (reaction(word, isCode) ?? idle[Math.floor(rand() * idle.length)]!)
448    beats.push({ from: at, to, move, kind, action, stay: isRun ? 0.08 : SECONDS[action], turn })
449    at = to
450  }
451  // x 处往下掉:落到下面第一个接得住的平台上(字或者地面)
452  const below = (x: number, y: number) =>
453    floors.find(f => f.y > y + 2 && x >= f.x0 - CRAB_W / 2 && x <= f.x1 + CRAB_W / 2) ?? floors.at(-1)!
454  const clampTo = (f: Floor, x: number) => Math.max(f.x0, Math.min(f.x1, x))
455
456  // 先落到一个平台上:起点在空中就往下掉;从下面上来就跳到最低的那行字上
457  let here: Floor
458  if (args.enter === 'below') {
459    here = floors.length > 1 ? floors.at(-2)! : floors.at(-1)!
460    go({ x: clampTo(here, begin.x), y: here.y }, 'drop', '', false, true)
461  } else {
462    const standing = floors.find(f => Math.abs(f.y - begin.y) < 3 && begin.x >= f.x0 - 4 && begin.x <= f.x1 + 4)
463    here = standing ?? below(begin.x, begin.y - 1 - (args.enter === 'above' ? 0 : 0))
464    if (!standing) go({ x: clampTo(here, begin.x), y: here.y }, 'drop', '', false, true)
465  }
466  // 只在字上:一行一行蛇形往下,行尾直接跳到下一行的字上;到最后一行就沿字的左边缘爬回第一行
467  const texts = floors.filter(f => !f.isGround)
468  if (here.isGround) here = texts.at(-1) ?? here
469  // 蛇形的方向跟着行号:第一行往右,第二行往左……重新规划时也接得上
470  let dir = Math.max(0, texts.indexOf(here)) % 2 === 0 ? 1 : -1
471  for (let visits = 0; visits < CHUNK + 1 && beats.length < 120; visits++) {
472    const ahead = here.words.filter(w => (dir > 0 ? w.x > at.x + 8 : w.x < at.x - 8))
473    const route = dir > 0 ? ahead : [...ahead].reverse()
474    route.forEach((w, k) => go({ x: w.x, y: here.y }, 'hop', w.word + (route[k + 1]?.word ?? ''), here.isCode))
475    const index = texts.indexOf(here)
476    const next = texts[index + 1]
477    if (next && visits === CHUNK) break // 这一段爬够了,停在这里,下一段从这里接着往下
478    if (next) {
479      // 在这一行上走到下一行的尽头正上方(下一行比这行长,就是这行的行尾),
480      // 侧过身翻过边缘往下爬到下一行(脚冲着字);全程都踩着字,不在空白里走
481      const end = dir > 0 ? here.x1 : here.x0
482      const down = Math.max(here.x0, Math.min(here.x1, clampTo(next, end)))
483      go({ x: down, y: here.y }, 'hop', '', false, true)
484      go({ x: down, y: next.y }, 'climb', '', false, true, dir > 0 ? 'rotate(90deg)' : 'rotate(-90deg)')
485      here = next
486      dir = -dir
487      continue
488    }
489    // 最后一行:从这些行里最靠右的那个“字的开头”竖着爬回第一行(段落从 0 开始、列表缩进 31,
490    // 取最靠右的才能保证一路经过的每一行脚下都有字),到顶再走回第一行的开头
491    const climbX = Math.min(here.x1, Math.max(...texts.slice(0, index + 1).map(f => f.x0)))
492    go({ x: climbX, y: here.y }, 'hop', '', false, true)
493    go({ x: climbX, y: texts[0]!.y }, 'climb', '', false, true, 'rotate(-90deg)')
494    if (texts[0]!.x0 !== climbX) go({ x: texts[0]!.x0, y: texts[0]!.y }, 'walk', '', false, true)
495    here = texts[0]!
496    dir = 1
497  }
498  const total = beats.reduce((sum, one) => sum + one.move + one.stay, 0)
499
500  return { beats, total, height }
501}
502
503const crabAt = (p: Point, inner: string, cls = '') =>
504  `<g class="mover" style="transform: translate(${p.x}px, ${p.y}px)"><g class="bob ${cls}">${inner}</g></g>`
505
506const svg = (width: number, height: number, css: string, body: string) =>
507  `<svg xmlns="http://www.w3.org/2000/svg" shape-rendering="crispEdges" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><style>${BASE_CSS}${css}</style>${body}</svg>`
508
509const perform = (args: SceneArgs) => {
510  const { beats, total } = plan(args)
511  const isRun = args.mode === 'run'
512  const pct = (seconds: number) => (seconds / total) * 100
513  const mover: Track = []
514  const bob: Track = [[0, 'none'], [100, 'none']]
515  const eyes: Track = [[0, 'none'], [100, 'none']]
516  const armL: Track = [[0, 'none'], [100, 'none']]
517  const armR: Track = [[0, 'none'], [100, 'none']]
518  let t = 0
519  for (const beat of beats) {
520    const start = pct(t)
521    const land = pct(t + beat.move)
522    const end = pct(t + beat.move + beat.stay)
523    const { from, to } = beat
524    mover.push([start, `translate(${from.x}px, ${from.y}px)`])
525    if (beat.kind === 'hop') mover.push([(start + land) / 2, `translate(${(from.x + to.x) / 2}px, ${Math.min(from.y, to.y) - 12}px)`])
526    if (beat.kind === 'drop') {
527      // 往下:先跳一下再落;往上:先蹲一下再蹦
528      const isDown = to.y >= from.y
529      mover.push([start + (land - start) * 0.3, `translate(${from.x + (to.x - from.x) * 0.3}px, ${isDown ? from.y - 10 : to.y - 12}px)`])
530    }
531    // 贴墙爬:身子转过来,脚冲着墙
532    const turn = beat.turn ?? (from.x > (args.col ?? COL_W) / 2 ? 'rotate(90deg)' : 'rotate(-90deg)')
533    if (beat.kind === 'climb') bob.push([start, 'none'], [start + 0.01, turn], [land - 0.01, turn], [land, 'none'])
534    mover.push([land, `translate(${to.x}px, ${to.y}px)`])
535    mover.push([end, `translate(${to.x}px, ${to.y}px)`])
536    if (beat.kind === 'hop' || beat.kind === 'drop') bob.push([land, 'none'], [land + (end - land) * 0.15, 'scaleY(0.86)'], [land + (end - land) * 0.4, 'none'])
537    const span = end - land
538    const at_ = (f: number) => land + span * f
539    const { action } = beat
540    if (action === 'look') eyes.push([at_(0.1), 'none'], [at_(0.3), 'translateX(-3px)'], [at_(0.6), 'translateX(3px)'], [at_(0.9), 'none'])
541    if (action === 'wave') {
542      for (const f of [0.15, 0.45, 0.75]) armL.push([at_(f), 'translateY(-5px)'], [at_(f + 0.15), 'none'])
543      for (const f of [0.3, 0.6]) armR.push([at_(f), 'translateY(-5px)'], [at_(f + 0.15), 'none'])
544    }
545    if (action === 'sit') bob.push([at_(0.45), 'none'], [at_(0.5), 'scaleY(0.8)'], [at_(0.9), 'scaleY(0.8)'], [at_(0.97), 'none'])
546    if (action === 'stomp') for (const f of [0.45, 0.7]) bob.push([at_(f), 'none'], [at_(f + 0.1), 'translateY(-5px)'], [at_(f + 0.2), 'scaleY(0.85)'], [at_(f + 0.25), 'none'])
547    if (action === 'scared') {
548      bob.push([at_(0.42), 'none'], [at_(0.5), 'translateY(-14px)'], [at_(0.6), 'none'])
549      for (const f of [0.65, 0.73, 0.81]) bob.push([at_(f), 'translateX(-2px)'], [at_(f + 0.04), 'translateX(2px)'])
550      bob.push([at_(0.9), 'none'])
551      eyes.push([at_(0.4), 'none'], [at_(0.45), 'scale(1.4)'], [at_(0.9), 'scale(1.4)'], [at_(0.97), 'none'])
552      armL.push([at_(0.4), 'none'], [at_(0.48), 'translateY(-6px)'], [at_(0.88), 'translateY(-6px)'], [at_(0.95), 'none'])
553      armR.push([at_(0.4), 'none'], [at_(0.48), 'translateY(-6px)'], [at_(0.88), 'translateY(-6px)'], [at_(0.95), 'none'])
554    }
555    if (action === 'happy') {
556      bob.push([at_(0.42), 'none'], [at_(0.58), 'translateY(-10px) scaleX(-1)'], [at_(0.74), 'scaleX(1)'], [at_(0.88), 'translateY(-6px) scaleX(-1)'], [at_(0.97), 'none'])
557      armL.push([at_(0.42), 'none'], [at_(0.5), 'translateY(-5px)'], [at_(0.92), 'translateY(-5px)'], [at_(0.98), 'none'])
558      armR.push([at_(0.42), 'none'], [at_(0.5), 'translateY(-5px)'], [at_(0.92), 'translateY(-5px)'], [at_(0.98), 'none'])
559    }
560    if (action === 'typing') {
561      bob.push([at_(0.42), 'none'], [at_(0.48), 'scaleY(0.9)'], [at_(0.92), 'scaleY(0.9)'], [at_(0.97), 'none'])
562      for (let f = 0.48; f < 0.88; f += 0.08) {
563        armL.push([at_(f), 'translateY(2px)'], [at_(f + 0.04), 'none'])
564        armR.push([at_(f + 0.04), 'translateY(2px)'], [at_(f + 0.08), 'none'])
565      }
566    }
567    t += beat.move + beat.stay
568  }
569  // 第一圈带着“进场”,之后从头循环会瞬移,所以整段只播一遍;下一次重画会接着走
570  const d = `${total.toFixed(2)}s`
571  const css = `
572    .mover { animation: ride ${d} linear forwards; --step: ${isRun ? '0.14s' : '0.32s'}; }
573    .bob { animation: bob ${d} linear forwards; }
574    .eyes { animation: eyes ${d} linear forwards; }
575    .arm-l { animation: arm-l ${d} linear forwards; }
576    .arm-r { animation: arm-r ${d} linear forwards; }
577    ${keyframes('ride', mover)} ${keyframes('bob', bob)} ${keyframes('eyes', eyes)} ${keyframes('arm-l', armL)} ${keyframes('arm-r', armR)}`
578
579  return { css, start: beats[0]?.from ?? { x: 0, y: 0 }, total }
580}
581
582// 这段表演要多久(播完就该换一段新的接着走)
583export const durationOf = (args: SceneArgs) => (args.mode === 'roam' || args.mode === 'run' ? plan(args).total : Infinity)
584
585// 表演开始 seconds 秒后螃蟹在哪
586export const positionAt = (args: SceneArgs, seconds: number): Point => {
587  if (args.mode !== 'roam' && args.mode !== 'run') return restAt(args)
588  const { beats } = plan(args)
589  let t = Math.max(0, seconds)
590  for (const beat of beats) {
591    if (t < beat.move) {
592      const f = t / beat.move
593
594      return { x: beat.from.x + (beat.to.x - beat.from.x) * f, y: beat.from.y + (beat.to.y - beat.from.y) * f }
595    }
596    t -= beat.move
597    if (t < beat.stay) return beat.to
598    t -= beat.stay
599  }
600
601  return beats.at(-1)?.to ?? restAt(args)
602}
603
604// 静止的心情待在哪:接着原来的位置;没有就挑一个落脚点
605const restAt = (args: SceneArgs): Point => {
606  if (args.startX !== undefined && args.startY !== undefined && args.enter === undefined) return { x: args.startX, y: args.startY }
607  const { stops } = stopsOf(args.text, args.col)
608  const one = stops[args.seed % stops.length]!
609
610  return { x: args.startX ?? one.x, y: one.y }
611}
612
613// 校准:每一行估计的位置画一个框(从字的开头到结尾、从行顶到行底),框上沿就是螃蟹落脚的高度
614const guides = (text: string, col: number) =>
615  layout(text, col)
616    .lines.map((line, i) => {
617      const y = Math.round(line.top + GLYPH_TOP)
618      const kind = line.parts ? 'tb' : line.isCode ? 'code' : line.x0 > 0 ? 'li' : 'p'
619      const h = Math.max(6, Math.round(line.height - GLYPH_TOP - 2))
620      const boxes = (line.parts ?? [line]).map(part =>
621        `<rect x="${part.x0}" y="${y}" width="${Math.max(20, part.x1 - part.x0)}" height="${h}" fill="#e5484d" fill-opacity="0.06" stroke="#e5484d" stroke-opacity="0.8" stroke-width="1"/>`)
622      return `${boxes.join('')}<text x="${Math.min(col - 40, line.x1 + 4)}" y="${y + 9}" font-size="9" fill="#e5484d" font-family="monospace">${i}${kind}</text>`
623    })
624    .join('')
625
626// 校准时的尺子:每 50px 一个刻度,标着数字,画到当前设定的正文宽为止。
627// 文字超出尺子末端,说明设窄了;尺子比最长的文字长很多,说明设宽了
628const ruler = (width: number) =>
629  Array.from({ length: Math.floor(width / 10) + 1 }, (_, i) => {
630    const x = i * 10
631    const isMajor = x % 50 === 0
632    return `<rect x="${x}" y="0" width="1" height="${isMajor ? 10 : 4}" fill="#2563eb" opacity="0.8"/>${isMajor && x > 0 ? `<text x="${x + 2}" y="18" font-size="9" fill="#2563eb" font-family="monospace">${x}</text>` : ''}`
633  }).join('')
634
635export const scene = (args: SceneArgs) => {
636  const col = args.col ?? COL_W
637  const { width, height } = sceneSize(args.text, col)
638  // 图不能比消息框宽:宽了桌面版会把整张图等比缩小(尺子和框都会跟着变形),所以尺子也只画到正文宽
639  const draw = (css: string, body: string) => svg(width, height, css, (args.isDebug ? ruler(width) + guides(args.text, col) : '') + body)
640  const { mode } = args
641
642  if (mode === 'roam' || mode === 'run') {
643    const { css, start } = perform(args)
644
645    return draw(css, crabAt(start, crab(false)))
646  }
647
648  const here = restAt(args)
649
650  if (mode === 'leave') {
651    const { lines } = stopsOf(args.text, col)
652    const isDown = args.exit !== 'up'
653    // 往下:走到这一行的边缘,跳下去,一直掉出这条回复;往上:走到左边缘,贴着边爬出上沿
654    const line = lines.find(one => Math.abs(standOn(one) - here.y) < 6)
655    const edge = isDown ? Math.min(col - CRAB_W, Math.max(here.x, (line?.x1 ?? here.x + 60) - CRAB_W)) : Math.round(line?.x0 ?? here.x)
656    const css = isDown
657      ? `.mover { animation: leave 0.9s ease-in forwards; }
658         @keyframes leave { 0% { transform: translate(${here.x}px, ${here.y}px); } 45% { transform: translate(${edge}px, ${here.y}px); } 60% { transform: translate(${edge + 14}px, ${here.y - 10}px); } 100% { transform: translate(${edge + 24}px, ${height}px); } }`
659      : `.mover { animation: leave 0.9s linear forwards; }
660         .bob { animation: turn 0.9s steps(1) forwards; }
661         @keyframes turn { 0% { transform: none; } 40% { transform: rotate(-90deg); } }
662         @keyframes leave { 0% { transform: translate(${here.x}px, ${here.y}px); } 40% { transform: translate(${edge}px, ${here.y}px); } 100% { transform: translate(${edge}px, ${-CRAB_H * 2}px); } }`
663
664    return draw(css, crabAt(here, crab(false)))
665  }
666
667  if (mode === 'dig') {
668    const css = `
669      .bob { animation: dig 0.36s ease-in-out infinite; }
670      @keyframes dig { 50% { transform: translateY(3px) scaleY(0.9); } }
671      .arm-l, .arm-r { animation: scoop 0.36s ease-in-out infinite; }
672      .arm-r { animation-delay: 0.18s; }
673      @keyframes scoop { 50% { transform: translateY(3px); } }
674      .dirt { animation: dirt 0.72s ease-out infinite; opacity: 0; }
675      .d2 { animation-delay: 0.24s; } .d3 { animation-delay: 0.48s; }
676      @keyframes dirt { 0% { opacity: 1; transform: translate(0, 0); } 100% { opacity: 0; transform: translate(var(--dx), -16px); } }`
677    const dirt = ([['d1', -12, 2], ['d2', 12, 7], ['d3', -5, 10]] as const)
678      .map(([cls, dx, px]) => `<rect class="dirt ${cls}" style="--dx:${dx}px" x="${here.x + px * PX}" y="${here.y + CRAB_H - 4}" width="3" height="3" fill="#8a6a50"/>`)
679      .join('')
680
681    return draw(css, crabAt(here, crab(false)) + dirt)
682  }
683
684  if (mode === 'trip') {
685    const css = `
686      .bob { animation: trip 2.2s ease-in-out forwards; }
687      @keyframes trip { 0% { transform: rotate(0); } 18% { transform: translateY(2px) rotate(-100deg); } 70% { transform: translateY(2px) rotate(-100deg); } 85% { transform: translateY(-8px) rotate(0); } 100% { transform: rotate(0); } }`
688
689    return draw(css, crabAt(here, crab(false), 'still'))
690  }
691
692  const isDream = mode === 'sleep'
693  const side = Math.max(0, Math.min(here.x, width - CRAB_W - 60))
694  const css = `
695    .bob { animation: breathe ${isDream ? 4.4 : 3.6}s ease-in-out infinite; }
696    @keyframes breathe { 0%, 100% { transform: scaleY(0.85); } 50% { transform: scaleY(0.78); } }
697    .z { font: bold 10px sans-serif; fill: #999; animation: z 3.6s linear infinite; opacity: 0; }
698    .z2 { animation-delay: 1.8s; }
699    @keyframes z { 0% { opacity: 0; transform: translate(0, 0); } 20% { opacity: 1; } 100% { opacity: 0; transform: translate(10px, -16px); } }
700    .bubble { animation: float 4.4s ease-in-out infinite; transform-origin: bottom left; }
701    @keyframes float { 50% { transform: translateY(-2px) scale(1.06); } }
702    .dot { animation: dot 1.8s infinite; opacity: 0.2; } .dot2 { animation-delay: 0.3s; } .dot3 { animation-delay: 0.6s; }
703    @keyframes dot { 30% { opacity: 1; } }`
704  const top = here.y + 4
705  const extra = isDream
706    ? `<g class="bubble">
707        <circle cx="${side + CRAB_W + 2}" cy="${top}" r="2" fill="#bbb"/>
708        <circle cx="${side + CRAB_W + 8}" cy="${top - 7}" r="3" fill="#bbb"/>
709        <ellipse cx="${side + CRAB_W + 32}" cy="${top - 14}" rx="20" ry="8" fill="#e6e6e6" stroke="#bbb"/>
710        ${[0, 1, 2].map(i => `<circle class="dot dot${i + 1}" cx="${side + CRAB_W + 24 + i * 8}" cy="${top - 14}" r="2" fill="#777"/>`).join('')}
711      </g>`
712    : `<text class="z" x="${side + CRAB_W}" y="${top}">z</text><text class="z z2" x="${side + CRAB_W}" y="${top}">z</text>`
713
714  return draw(css, crabAt({ x: side, y: here.y }, crab(true), 'still') + extra)
715}
716
types/index.d.ts 22 lines
1// clawd 的状态:由事件钩子写,画回复的钩子读;写入时只重画读它的回复
2export type ClawdMode = 'roam' | 'run' | 'dig' | 'trip' | 'doze' | 'sleep' | 'leave'
3
4// 螃蟹在哪条回复上(回复文字去掉标点后的前 24 个字),以及最近几条可去的回复
5// since:这段表演开始的时间;startX/startY:从哪里接着走;enter/exit:在回复之间上下移动的方向
6export type ClawdSpot = {
7  target: string
8  seed: number
9  recent: string[]
10  since: number
11  startX?: number
12  startY?: number
13  enter?: 'above' | 'below'
14  exit?: 'down' | 'up'
15}
16
17declare module 'claude-code' {
18  interface PluginState {
19    clawd: { spot: ClawdSpot; mode: ClawdMode; isHidden: boolean; isDebug: boolean; width: number }
20  }
21}
22