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

两个 Claude Code mod:
/goodnight(/goodnight 0 立刻入梦),或者你空闲 2 小时后自动睡着。/dreams 打开梦境日记。要知道的:
~/.claude/projects 下所有会话记录和记忆文件,抽出的片段会作为提示发给模型(用你自己的 Claude 账号)。sonnet),会消耗 token。命令:
| 命令 | 作用 |
|---|---|
/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
MIT
hooks/register.tsx 208 lines1import 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}
208hooks/sprite.ts 716 lines1// 小螃蟹的像素画和动画。一张透明 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}
716types/index.d.ts 22 lines1// 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