Hop: a hop game to play while Claude works, with an AI coding tip on every block / 蹦一蹦:等 Claude 干活时玩的跳一跳,每块方块带一张 AI 编程知识卡片

English · 简体中文
A hop game to play while Claude works. When a turn has been running for 5 seconds, a "Play Hop" hint appears above the prompt; you can also type /hop any time. Click the box under the game, hold Space to charge, release to jump.
afplay); English and ChineseStays out of the conversation: the pane, hint and cards are drawn in the UI only. The two lines /hop leaves in the transcript are blanked to "(no content)". Network: only after you join the leaderboard does it call https://agihunt.info/agent/v1/hop (submit scores, fetch the board, leave and delete). Requires: the Claude Code desktop app, 2.1.287 or later (the game is drawn with the desktop app's SVG surface; it doesn't show in the terminal).
Run /plugin and open hop:
| Setting | Default | What it does |
|---|---|---|
| Hint while Claude works | on | Show the hint above the prompt during long turns |
| Hint after (seconds) | 5 | How long into a turn before the hint shows |
| Open the pane by itself | off | Open the game pane instead of only hinting |
| Language | auto | auto follows the system language; en or zh forces English or Chinese (start a new session to apply) |
| File | What it does |
|---|---|
hooks/register.tsx | All hooks: when to appear, key handling, frame-by-frame redraws, sounds, leaderboard requests, pane layout |
hooks/game.ts | Pure game logic: block generation, jump physics, scoring, difficulty curve (no UI, easy to unit test) |
hooks/scene.ts | Draws a game state as one static SVG frame: blocks, hero (or whale), animation, cards, results |
hooks/facts.ts | Loads the knowledge cards and queues unseen ones first |
hooks/i18n.ts | English and Chinese strings; add a language here |
data/facts.json | Knowledge cards with sources; format in the repo's CONTRIBUTING |
sounds/*.wav | Sounds synthesized by scripts/make_sounds.py in the repo; tweak and regenerate |
tests/ | Tests run by claude plugin test |
SPEED (distance per millisecond held) and MAX_HOLD_MS at the top of game.ts, and the difficulty curve in nextBlock.SPECIAL in game.ts (bonus points); KIND_COLOR and decal in scene.ts (looks).FLY, PAN, DROP, POP in scene.ts.turn.start, classic.Notification and turn.complete hooks in register.tsx.The DeepSeek Harness version reuses game.ts, scene.ts, facts.ts, i18n.ts and the cards; rebuild it after changing them.
hooks/register.tsx 685 lines1import type { Register } from 'claude-code'
2
3import type { Board, BoardEntry, BoardTab, ClaudeStatus, Game, View } from '../types'
4import type { Fact } from './facts'
5import { queueFor, setFacts } from './facts'
6import { jump, newGame, SPECIAL } from './game'
7import { lang, langFromAppleLanguages, setLang, tr } from './i18n'
8import { ANIM_MS, chargeFrame, idleMoving, jumpFrame, setCanvas, stillFrame } from './scene'
9
10const PANE = 'hop'
11const TITLE = '蹦一蹦'
12const VIEW = { plugin: 'hop', key: 'view' } as const
13const TICK = { plugin: 'hop', key: 'tick' } as const
14const BOARD = { plugin: 'hop', key: 'board' } as const
15const CLAUDE = { plugin: 'hop', key: 'claude' } as const
16const IDLE: ClaudeStatus = { busy: false, needsYou: false, done: false, hint: false }
17const EMPTY: View = { game: null, before: null, phase: 'idle', jump: null, hint: '', pad: 0, chargeAt: 0, jumpAt: 0, startedAt: 0 }
18const NO_BOARD: Board = { joined: false, nickname: '', tab: 'today', entries: [], me: null, status: '', busy: false, slow: false }
19
20// 桌面端拿不到「按下 / 松开」,按钮快捷键也收不到按住时的重复按键。
21// 输入框能收到:在框里按住空格,系统会不停往里打空格,每打一个都通知插件;
22// 空格停了就当松手。系统要按住一会儿才开始重复,这段时间内松开的算轻点。
23const RELEASE_GAP_MS = 100
24// 系统开始重复前要按住多久(macOS 的 InitialKeyRepeat × 15ms),开机时读一次
25let repeatDelayMs = 450
26// 画面是静态图,动画由插件逐帧重画:按住和起跳期间每 33 毫秒画一帧,其余时间不动
27const FRAME_MS = 33
28
29let holdStart = 0
30let lastPress = 0
31let presses = 0
32// 起跳动画画到什么时候为止
33let animUntil = 0
34// 声音:用系统自带的 afplay 播 sounds/ 下的 wav;蓄力那段按多久响多久,松手就掐掉
35const FLY_MS = 450
36const VOLUME = '0.6'
37let muted = false
38// 待机时也画小动作(呼吸、眨眼、挥手),但只在面板露着的时候,大约每秒 10 帧
39const IDLE_FRAME_MS = 100
40let isShown = false
41let lastIdleFrame = 0
42let lastShownCheck = 0
43// 当前这一局是否已经结束(结束后不画待机动作)
44let isOverNow = false
45// 面板此刻是否握着键盘(你正在玩):这时不画待机小动作,免得面板重画让输入框边框跟着闪
46let paneFocused = false
47// 每局最多预排多少条冷知识
48const QUEUE = 80
49
50// 排行榜(agihunt.info 后端):自愿加入;身份是本机随机生成的 player_id + secret,存在插件自己的 store 里
51const LB_BASE = 'https://agihunt.info/agent/v1/hop'
52const VERSION = '0.5.2'
53type Identity = { playerId: string; secret: string; nickname: string; joined: boolean }
54// 一局的成绩。没传上去的先存进 store 的 pending,下次刷新榜单时补传
55type Run = { score: number; jumps: number; perfects: number; durationMs: number }
56// 只有这一个刷新算数:切榜或连着刷新时,先发出去、后回来的旧结果直接丢掉
57let boardSeq = 0
58let flushing: Promise<void> | null = null
59type Lb = {
60 refresh: (tab?: BoardTab) => Promise<void>
61 join: (nickname: string, game: Game | null, durationMs: number) => Promise<void>
62 submit: (game: Game, durationMs: number) => Promise<void>
63 leave: () => Promise<void>
64}
65// session.start 里建好,渲染时的按钮也用它
66let lb: Lb | null = null
67
68// 出现时机:Claude 一轮跑了几秒还没完,才在输入框上方提示;需要你时让开;闲着时不出现。
69// 全程只画界面、只观察事件,不往对话里写任何东西。
70type Options = { autoHint?: boolean; hintAfterSeconds?: number; autoOpen?: boolean; language?: string }
71let opts: Options = {}
72let turnStart = 0
73let hinted = false
74let hintMuted = false
75// 刚输入了 /hop:用来认出它前面那行说明
76let hopPending = false
77let chargeSound: { return?: (v?: undefined) => unknown } | null = null
78
79export const register: Register = (on, options) => {
80 opts = (options ?? {}) as Options
81 on('session.start', async ($, e, next) => {
82 // 语言:设置里指定了 zh / en 就用它,否则跟随系统语言
83 const forced = String(opts.language ?? 'auto').trim().toLowerCase()
84 if (forced === 'zh' || forced === 'en') setLang(forced)
85 else {
86 const langs = await $.process.run(['defaults', 'read', '-g', 'AppleLanguages']).catch(() => null)
87 setLang(langFromAppleLanguages(langs?.stdout ?? ''))
88 }
89 await $.command.register({ name: 'hop', description: tr().command, immediate: true })
90 const data = await $.fs.read(`${$.plugin.root}/data/facts.json`).catch(() => null)
91 if (data) setFacts(JSON.parse(data) as Fact[])
92 const { value: view = EMPTY } = await $.state.get(VIEW)
93 muted = (await $.store.get('muted')) === true
94 const delay = await $.process.run(['defaults', 'read', '-g', 'InitialKeyRepeat']).catch(() => null)
95 const ticks = Number(delay?.stdout.trim())
96 repeatDelayMs = Number.isFinite(ticks) && ticks > 0 ? ticks * 15 : 450
97 // 热重载会保留上一版的存档;格式对不上(旧版方块的冷知识是数字编号、没有冷知识队列)就重开一局
98 const isStale =
99 view.game !== null &&
100 (!Array.isArray(view.game.factQueue) || view.game.blocks.some(b => b.fact !== null && typeof b.fact !== 'string'))
101 isOverNow = view.game?.isOver === true && !isStale
102 if (!view.game || isStale) {
103 const best = Number((await $.store.get('best')) ?? 0)
104 const seed = Math.floor((await $.clock.now()) % 2147483647)
105 const seen = ((await $.store.get('seen')) ?? []) as string[]
106 await $.state.set(VIEW, { ...EMPTY, game: newGame(seed, best, queueFor(seen, seed).slice(0, QUEUE)) })
107 }
108
109 // 排行榜接口
110 const call = async (method: string, path: string, body?: unknown) => {
111 try {
112 const r = await $.http.fetch(`${LB_BASE}${path}`, {
113 method,
114 headers: { 'Content-Type': 'application/json', 'User-Agent': `agihunt-hop/${VERSION}` },
115 body: body === undefined ? undefined : JSON.stringify(body),
116 })
117 let data: Record<string, unknown> | null = null
118 try {
119 data = JSON.parse(r.text) as Record<string, unknown>
120 } catch {
121 data = null
122 }
123 return { ok: r.ok, status: r.status, data }
124 } catch {
125 return { ok: false, status: 0, data: null }
126 }
127 }
128 const errorText = (data: Record<string, unknown> | null, status: number): string => {
129 const code = (data?.error as { code?: string } | undefined)?.code ?? ''
130 if (code.startsWith('nickname')) return tr().lbBadNick
131 if (status === 429) return tr().lbTooFast
132 return tr().lbOffline
133 }
134 const identity = async (): Promise<Identity> => {
135 const saved = (await $.store.get('lb')) as Identity | undefined
136 if (saved?.playerId) return saved
137 const bytes = crypto.getRandomValues(new Uint8Array(32))
138 const secret = btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
139 const fresh: Identity = { playerId: crypto.randomUUID(), secret, nickname: '', joined: false }
140 await $.store.set('lb', fresh)
141 return fresh
142 }
143 const patch = async (change: Partial<Board>) => {
144 const { value: b = NO_BOARD } = await $.state.get(BOARD)
145 await $.state.set(BOARD, { ...b, ...change })
146 }
147 const post = (id: Identity, run: Run) =>
148 call('POST', '/scores', {
149 player_id: id.playerId,
150 secret: id.secret,
151 score: run.score,
152 jumps: run.jumps,
153 perfects: run.perfects,
154 duration_ms: Math.max(0, Math.round(run.durationMs)),
155 client_version: VERSION,
156 lang: lang(),
157 })
158 // 网络断了、服务器忙、太快了:这类失败留着下次再传;分数不合理之类的直接丢掉
159 const isRetryable = (status: number) => status === 0 || status === 429 || status >= 500
160 const queue = async (runs: Run[]) => {
161 const pending = ((await $.store.get('pending')) ?? []) as Run[]
162 const all = [...pending, ...runs].filter(
163 (r, i, a) => a.findIndex(o => o.score === r.score && o.jumps === r.jumps && o.perfects === r.perfects) === i,
164 )
165 // 最多留 5 局,分高的优先
166 await $.store.set('pending', all.sort((a, b) => b.score - a.score).slice(0, 5))
167 }
168 const flush = (id: Identity): Promise<void> => {
169 flushing ??= (async () => {
170 const pending = ((await $.store.get('pending')) ?? []) as Run[]
171 if (pending.length === 0) return
172 const left: Run[] = []
173 for (const run of pending) {
174 const r = await post(id, run)
175 if (!r.ok && isRetryable(r.status)) left.push(run)
176 }
177 await $.store.set('pending', left)
178 })().finally(() => {
179 flushing = null
180 })
181 return flushing
182 }
183 lb = {
184 refresh: async tab => {
185 const id = await identity()
186 const { value: b = NO_BOARD } = await $.state.get(BOARD)
187 const board = tab ?? b.tab
188 // 加入 / 退出可能是在另一个会话里做的:以 store 里的为准
189 if (!id.joined) {
190 await patch({ tab: board, joined: false, nickname: '', busy: false, slow: false })
191 return
192 }
193 const mine = ++boardSeq
194 await patch({ tab: board, joined: true, nickname: id.nickname, busy: true, slow: false })
195 $.clock.after(3000, () => {
196 if (mine === boardSeq) void patch({ slow: true })
197 })
198 await flush(id)
199 const path = `/leaderboard?board=${board}&limit=10&player_id=${id.playerId}`
200 let r = await call('GET', path)
201 if (r.status === 0 && mine === boardSeq) r = await call('GET', path)
202 if (mine !== boardSeq) return
203 if (!r.ok || !r.data) {
204 await patch({ busy: false, slow: false, status: errorText(r.data, r.status) })
205 return
206 }
207 const raw = (r.data.entries ?? []) as { rank: number; nickname: string; score: number; is_me?: boolean }[]
208 const entries: BoardEntry[] = raw.map(e => ({ rank: e.rank, nickname: e.nickname, score: e.score, isMe: e.is_me === true }))
209 const me = r.data.me as { rank: number; score: number } | null
210 await patch({ busy: false, slow: false, status: '', entries, me: me ? { rank: me.rank, score: me.score } : null })
211 },
212 // 一局结束:加入了就排进待传队列,刷新时一起传;没加入只同步一下加入状态
213 submit: async (game, durationMs) => {
214 const id = await identity()
215 if (id.joined && game.score >= 1) {
216 await queue([{ score: game.score, jumps: game.jumps, perfects: game.perfects, durationMs }])
217 }
218 await lb?.refresh()
219 },
220 join: async (nickname, game, durationMs) => {
221 const name = nickname.trim()
222 if (!name) return
223 const id = await identity()
224 await patch({ busy: true, status: '' })
225 const r = await call('PUT', '/player', {
226 player_id: id.playerId,
227 secret: id.secret,
228 nickname: name,
229 client_version: VERSION,
230 lang: lang(),
231 })
232 if (!r.ok) {
233 await patch({ busy: false, status: errorText(r.data, r.status) })
234 return
235 }
236 const saved = (r.data?.nickname as string | undefined) ?? name
237 await $.store.set('lb', { ...id, nickname: saved, joined: true })
238 await patch({ joined: true, nickname: saved, busy: false })
239 // 刚加入:这一局和本机的最高纪录都算上
240 const runs: Run[] = []
241 const bestRun = (await $.store.get('bestRun')) as Run | undefined
242 if (bestRun) runs.push(bestRun)
243 if (game?.isOver && game.score >= 1) runs.push({ score: game.score, jumps: game.jumps, perfects: game.perfects, durationMs })
244 if (runs.length > 0) await queue(runs)
245 await lb?.refresh()
246 },
247 leave: async () => {
248 const id = await identity()
249 const r = await call('DELETE', '/player', { player_id: id.playerId, secret: id.secret })
250 if (!r.ok) {
251 await patch({ status: errorText(r.data, r.status) })
252 return
253 }
254 boardSeq += 1
255 await $.store.set('lb', { ...id, nickname: '', joined: false })
256 await $.store.set('pending', [])
257 await $.state.set(BOARD, { ...NO_BOARD, status: tr().lbLeft })
258 },
259 }
260 const known = (await $.store.get('lb')) as Identity | undefined
261 if (known?.joined) await patch({ joined: true, nickname: known.nickname })
262
263 // 每帧:按住或起跳动画期间请求重画;同时盯着输入,空格停了就起跳
264 holdStart = 0
265 animUntil = 0
266 $.clock.every(FRAME_MS, () => {
267 void $.clock.now().then(async now => {
268 // 用状态驱动重画(和按键触发的重画走同一条路,不会丢键盘焦点)
269 const after = (opts.hintAfterSeconds ?? 5) * 1000
270 if (turnStart !== 0 && !hinted && now - turnStart >= after) {
271 hinted = true
272 const { value: c = IDLE } = await $.state.get(CLAUDE)
273 if (c.busy && !c.needsYou && (opts.autoHint ?? true) && !hintMuted) {
274 if (opts.autoOpen) void $.ui.open({ id: PANE, title: TITLE }).catch(() => undefined)
275 else await $.state.set(CLAUDE, { ...c, hint: true })
276 }
277 }
278 const isAnimating = holdStart !== 0 || now < animUntil + 2 * FRAME_MS
279 if (now - lastShownCheck > 1000) {
280 lastShownCheck = now
281 isShown = (await $.ui.panes().catch(() => [])).some(p => p.id === PANE && p.isShown && p.isPlaced)
282 }
283 // 待机:只在小人眨眼、挥手的那一下重画;结束了(结算页、昵称框)就完全不重画,免得输入框边框跟着闪
284 const wantsIdle = isShown && !isOverNow && !paneFocused && idleMoving(now) && now - lastIdleFrame >= IDLE_FRAME_MS
285 if (isAnimating || wantsIdle) {
286 lastIdleFrame = now
287 await $.state.set(TICK, now)
288 }
289 if (holdStart === 0) return
290 const gap = now - lastPress
291 const isTap = presses < 2 && gap > repeatDelayMs + 80
292 const isReleased = presses >= 2 && gap > RELEASE_GAP_MS
293 if (!isTap && !isReleased) return
294
295 // 轻点:系统还没开始重复就松手了,量不出具体时长,按一下短按来跳
296 const held = isTap ? Math.min(250, repeatDelayMs * 0.5) : lastPress - holdStart + 30
297 holdStart = 0
298 presses = 0
299 void chargeSound?.return?.()
300 chargeSound = null
301
302 const { value: v = EMPTY } = await $.state.get(VIEW)
303 const g = v.game
304 // 换一个新的空输入框,焦点跟过去,攒下的空格就清掉了
305 const pad = v.pad + 1
306 if (!g || g.isOver) return
307 {
308 const result = jump(g, held)
309 // 最高分存在 store 里,各个会话共用:别的会话刷新了纪录,这里也跟着显示
310 const lastBest = Number((await $.store.get('best')) ?? 0)
311 result.game.best = Math.max(result.game.best, lastBest)
312 // 第一次落到特殊方块:提示一次它们有额外加分,之后不再提示
313 const landedSpecial = result.jump.result === 'land' && g.blocks[g.cur + 1].kind in SPECIAL
314 const specialExplained = (await $.store.get('specialExplained')) === true
315 if (landedSpecial && !specialExplained) await $.store.set('specialExplained', true)
316 await $.state.set(VIEW, {
317 game: result.game,
318 before: g,
319 phase: 'jump',
320 jump: result.jump,
321 hint:
322 landedSpecial && !specialExplained ? tr().specialHint : tr().held((held / 1000).toFixed(2)),
323 pad,
324 chargeAt: v.chargeAt,
325 jumpAt: now,
326 startedAt: v.startedAt || v.chargeAt,
327 })
328 animUntil = now + ANIM_MS
329 isOverNow = result.game.isOver
330 // 真的落到这块上了,它的冷知识才算看过
331 const shown = result.jump.result === 'land' ? g.blocks[g.cur + 1].fact : null
332 if (shown) {
333 const seen = ((await $.store.get('seen')) ?? []) as string[]
334 if (!seen.includes(shown)) await $.store.set('seen', [...seen, shown])
335 }
336
337 // 音效:起跳马上响,落地 / 掉下去等小人到了再响
338 const r = result.jump
339 const target = g.blocks[g.cur + 1]
340 const isRecord = result.game.isOver && result.game.score > lastBest
341 if (result.game.isOver) {
342 const durationMs = now - (v.startedAt || v.chargeAt)
343 if (isRecord) {
344 await $.store.set('best', result.game.score)
345 const { score, jumps, perfects } = result.game
346 await $.store.set('bestRun', { score, jumps, perfects, durationMs })
347 }
348 void lb?.submit(result.game, durationMs)
349 }
350 if (!muted) {
351 const root = `${$.plugin.root}/sounds`
352 void $.process.run(['afplay', '-v', VOLUME, `${root}/jump.wav`]).catch(() => undefined)
353 const landing =
354 r.result === 'fall'
355 ? ['fall.wav', isRecord ? 'record.wav' : 'over.wav']
356 : r.result === 'stay'
357 ? ['land.wav', isRecord ? 'record.wav' : 'over.wav']
358 : ['land.wav', ...(r.isPerfect ? ['perfect.wav'] : []), ...(target.kind in SPECIAL ? ['bonus.wav'] : [])]
359 $.clock.after(FLY_MS, () => {
360 void (async () => {
361 for (const [i, name] of landing.entries()) {
362 if (i > 0) await $.clock.sleep(r.result === 'land' ? 90 : 520)
363 void $.process.run(['afplay', '-v', VOLUME, `${root}/${name}`]).catch(() => undefined)
364 }
365 })()
366 })
367 }
368 }
369 void $.ui.focus({ requestId: PANE, key: `pad-${pad}` }).catch(() => undefined)
370 })
371 })
372
373 return next(e)
374 })
375
376 // /hop 本身会在对话里留两行(Claude Code 的说明 + 命令名),模型下一轮读得到。
377 // 这两行的内容清空:它们只是打开游戏面板的记录,对 Claude 没有用。
378 on('session.append', async ($, e, next) => {
379 if (e.door !== 'command') return next(e)
380 const text = JSON.stringify(e.message.content)
381 const isHop = text.includes('<command-name>/hop</command-name>')
382 if (isHop || (text.includes('local-command-caveat') && hopPending)) {
383 if (isHop) hopPending = false
384 return next({ ...e, message: { ...e.message, content: [] } })
385 }
386 return next(e)
387 })
388
389 on('command.run', { command: 'hop' }, async $ => {
390 // 命令先运行、后入对话记录:先记一笔,下面 session.append 认出这一次的两行
391 hopPending = true
392 await $.ui.open({ id: PANE, title: TITLE, focus: true })
393 // 不回任何文字,免得在对话里留一行
394 return {}
395 })
396
397 // 一轮开始:开始计时,几秒后再决定要不要提示
398 on('turn.start', async ($, e, next) => {
399 turnStart = await $.clock.now()
400 hinted = false
401 hopPending = false
402 await $.state.set(CLAUDE, { busy: true, needsYou: false, done: false, hint: false })
403 return next(e)
404 })
405
406 // Claude 停下来等你(要授权、在问你问题):提示条让开,面板顶上提醒你回去
407 on('classic.Notification', async ($, e, next) => {
408 const { value: c = IDLE } = await $.state.get(CLAUDE)
409 if (c.busy) {
410 await $.state.set(CLAUDE, { ...c, needsYou: true, hint: false })
411 const panes = await $.ui.panes().catch(() => [])
412 if (panes.some(p => p.id === PANE && p.isPlaced)) $.ui.toast(tr().needsYou)
413 }
414 return next(e)
415 })
416
417 on('classic.PermissionRequest', async ($, e, next) => {
418 const { value: c = IDLE } = await $.state.get(CLAUDE)
419 if (c.busy && !c.needsYou) await $.state.set(CLAUDE, { ...c, needsYou: true, hint: false })
420 return next(e)
421 })
422
423 // 你处理完、Claude 接着跑工具了:清掉「在等你」
424 on('tool.call', async ($, e, next) => {
425 const { value: c = IDLE } = await $.state.get(CLAUDE)
426 if (c.needsYou) await $.state.set(CLAUDE, { ...c, needsYou: false })
427 return next(e)
428 })
429
430 on('turn.complete', async ($, e, next) => {
431 turnStart = 0
432 await $.state.set(CLAUDE, { busy: false, needsYou: false, done: true, hint: false })
433 const panes = await $.ui.panes().catch(() => [])
434 if (panes.some(p => p.id === PANE && p.isPlaced)) $.ui.toast(tr().toastDone)
435
436 return next(e)
437 })
438
439 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
440 const { value: c = IDLE } = await $.state.get(CLAUDE)
441 if (!c.hint || c.needsYou || !c.busy || hintMuted || e.props.hasSurvey) return next(e)
442 const panes = await $.ui.panes().catch(() => [])
443 if (panes.some(p => p.id === PANE && p.isPlaced)) return next(e)
444 const { Box, Text, Button } = $.ui.resolve(e)
445 return (
446 <Box flexDirection="row" gap={1} alignItems="center">
447 <Text dimColor>{tr().bandBusy}</Text>
448 <Button
449 key="hop-open"
450 label={tr().bandPlay}
451 variant="primary"
452 onPress={() =>
453 void $.ui.open({ id: PANE, title: TITLE, focus: true }).then(() => $.state.set(CLAUDE, { ...c, hint: false }))
454 }
455 />
456 <Button
457 key="hop-mute"
458 label={tr().bandMute}
459 plain
460 dimColor
461 onPress={() => {
462 hintMuted = true
463 void $.state.set(CLAUDE, { ...c, hint: false })
464 }}
465 />
466 </Box>
467 )
468 })
469
470 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
471 // 画面板出了错就把原因直接画出来,免得桌面端只显示一句「Nothing to show yet」
472 try {
473 const t = $.ui.resolve(e)
474 const { Box, Text, Button } = t
475 paneFocused = e.props.isFocused
476 // 画布跟着面板大小走:面板越大,场景越大、看得越远;方块和小人保持原大小(不是把图片拉伸)
477 // 面板只告诉插件格数,按一格约 7.5 像素、一行约 20 像素估算
478 const { value: peek = EMPTY } = await $.state.get(VIEW)
479 const { value: lbPeek = NO_BOARD } = await $.state.get(BOARD)
480 // 画面下面的控件要占几行:状态行、输入框、分数行,结束时再加排行榜
481 const controlRows = 6 + (peek.game?.isOver ? (lbPeek.joined ? 17 : 7) : 0)
482 const canvasW = Math.min(1400, (e.props.bodyColumns || 60) * 7.5 - 24)
483 const roomH = e.viewport?.rows ? (e.viewport.rows - controlRows) * 20 : 360
484 // 不要竖着拉长:高度最多是宽度的 0.82 倍(原画面的比例)
485 const canvasH = Math.min(canvasW * 0.82, roomH, 900)
486 setCanvas(canvasW, canvasH)
487 const sceneW = Math.max(440, Math.round(canvasW))
488 const sceneH = Math.max(360, Math.round(canvasH))
489 const { value: view = EMPTY } = await $.state.get(VIEW)
490 const { value: board = NO_BOARD } = await $.state.get(BOARD)
491 const { value: claude = IDLE } = await $.state.get(CLAUDE)
492 const game = view.game
493
494 if (!game) return <Text dimColor>{tr().loading}</Text>
495 await $.state.get(TICK)
496 const now = await $.clock.now()
497 const svg =
498 view.phase === 'charge'
499 ? chargeFrame(game, now - view.chargeAt)
500 : view.phase === 'jump' && view.before && view.jump && now - view.jumpAt < ANIM_MS
501 ? jumpFrame(view.before, game, view.jump, now - view.jumpAt, now)
502 : stillFrame(game, now)
503 if (e.surface === 'terminal' || !('Svg' in t) || !('Input' in t)) {
504 return <Text dimColor>{tr().desktopOnly}</Text>
505 }
506
507 // 输入框里每多一个空格(按住时系统会不停地打)都会走到这里
508 const press = () =>
509 void $.clock.now().then(async now => {
510 if (holdStart === 0) {
511 const { value: v = EMPTY } = await $.state.get(VIEW)
512 if (!v.game || v.game.isOver) return
513 holdStart = now
514 lastPress = now
515 presses = 1
516 await $.state.set(VIEW, { ...v, phase: 'charge', chargeAt: now, startedAt: v.startedAt || now })
517 if (!muted) {
518 const s = $.process.spawn({ argv: ['afplay', '-v', VOLUME, `${$.plugin.root}/sounds/charge.wav`] })
519 chargeSound = s
520 void (async () => {
521 try {
522 for await (const _ of s) {
523 // 只是让它响着;松手时 return() 结束循环,afplay 随之退出
524 }
525 } catch {
526 // 播不了就算了
527 }
528 })()
529 }
530 return
531 }
532 lastPress = now
533 presses += 1
534 })
535
536 const restart = () =>
537 void $.clock.now().then(async now => {
538 isOverNow = false
539 const seed = Math.floor(now % 2147483647)
540 const seen = ((await $.store.get('seen')) ?? []) as string[]
541 const best = Math.max(game.best, Number((await $.store.get('best')) ?? 0))
542 await $.state.set(VIEW, {
543 ...EMPTY,
544 game: newGame(seed, best, queueFor(seen, seed).slice(0, QUEUE)),
545 pad: view.pad + 1,
546 })
547 await $.state.get(BOARD).then(({ value: b = NO_BOARD }) => $.state.set(BOARD, { ...b, status: '' }))
548 })
549
550 return (
551 <Box flexDirection="column" gap={1}>
552 {(claude.needsYou || claude.done) && (
553 <Box flexDirection="row" justifyContent="space-between" alignItems="center">
554 <Text bold color={claude.needsYou ? '#E63946' : '#2A9D8F'}>
555 {claude.needsYou ? tr().needsYou : tr().doneNow}
556 </Text>
557 <Button
558 key="back"
559 label={tr().backToClaude}
560 plain
561 onPress={() =>
562 void $.state
563 .set(CLAUDE, { ...claude, done: false })
564 .then(() => $.ui.close({ id: PANE }))
565 .catch(() => undefined)
566 }
567 />
568 </Box>
569 )}
570 {/* 不加 isInteractive:当普通图片显示,换图不闪 */}
571 <t.Svg source={svg} alt={`蹦一蹦,当前 ${game.score} 分`} width={sceneW} height={sceneH} />
572 <Box flexDirection="row" gap={1} alignItems="center">
573 {game.isOver ? (
574 <Button key="restart" label={tr().restart} hotkey={board.joined ? 'r' : undefined} variant="primary" autoFocus onPress={restart} />
575 ) : (
576 // 只有输入框收得到按住空格时的连续输入;宽度固定,空格攒多了也不会把框撑长
577 <Box width={34}>
578 <t.Input
579 key={`pad-${view.pad}`}
580 value=""
581 placeholder={tr().placeholder}
582 autoFocus
583 onInput={() => press()}
584 onSubmit={() => undefined}
585 />
586 </Box>
587 )}
588 <Text dimColor>{view.hint}</Text>
589 </Box>
590 {game.isOver && (
591 <Box flexDirection="column" borderStyle="round" borderDimColor paddingX={1}>
592 {board.joined ? (
593 <Box flexDirection="column">
594 {/* 当前榜单用醒目的字,其余两个是可点的按钮;右边是加载状态 */}
595 <Box flexDirection="row" justifyContent="space-between" alignItems="center">
596 <Box flexDirection="row" gap={2} alignItems="center">
597 {(['today', 'week', 'all'] as const).map(tab =>
598 board.tab === tab ? (
599 <Text key={`tab-${tab}`} bold color="#D97757">
600 {tr().lbTabs[tab]}
601 </Text>
602 ) : (
603 <Button
604 key={`tab-${tab}`}
605 label={tr().lbTabs[tab]}
606 plain
607 dimColor
608 onPress={() => void lb?.refresh(tab)}
609 />
610 ),
611 )}
612 </Box>
613 {board.busy && <Text dimColor>{board.slow ? tr().lbSlow : tr().lbBusy}</Text>}
614 </Box>
615 {board.entries.length === 0 ? (
616 <Text dimColor>{board.busy ? tr().lbLoading : board.status !== '' ? '' : tr().lbEmpty}</Text>
617 ) : (
618 <Box flexDirection="column" width={32}>
619 {board.entries.map(en => (
620 <Box key={`row-${en.rank}-${en.nickname}`} flexDirection="row" justifyContent="space-between">
621 <Text bold={en.isMe} color={en.isMe ? '#D97757' : undefined} dimColor={board.busy && !en.isMe}>
622 {`${String(en.rank).padStart(2, ' ')} ${en.nickname}`}
623 </Text>
624 <Text bold={en.isMe} color={en.isMe ? '#D97757' : undefined} dimColor={board.busy && !en.isMe}>
625 {String(en.score)}
626 </Text>
627 </Box>
628 ))}
629 </Box>
630 )}
631 <Box flexDirection="row" justifyContent="space-between">
632 <Text dimColor>{board.me ? tr().lbMe(board.me.rank, board.me.score) : tr().lbNotRanked}</Text>
633 <Button key="lb-leave" label={tr().lbLeave} plain dimColor onPress={() => void lb?.leave()} />
634 </Box>
635 </Box>
636 ) : (
637 <Box flexDirection="column">
638 <Text dimColor wrap="wrap">
639 {tr().lbInvite}
640 </Text>
641 <Box width={28}>
642 <t.Input
643 key="lb-nick"
644 placeholder={tr().lbNick}
645 onSubmit={name => void lb?.join(name, game, view.jumpAt - (view.startedAt || view.jumpAt))}
646 />
647 </Box>
648 </Box>
649 )}
650 {board.status !== '' && (
651 <Box flexDirection="row" gap={2} alignItems="center">
652 <Text color="#E76F51">
653 {board.status === tr().lbOffline && board.entries.length > 0 ? `${board.status}${tr().lbStale}` : board.status}
654 </Text>
655 {board.joined && !board.busy && board.status === tr().lbOffline && (
656 <Button key="lb-retry" label={tr().lbRetry} plain onPress={() => void lb?.refresh()} />
657 )}
658 </Box>
659 )}
660 </Box>
661 )}
662 <Box flexDirection="row" justifyContent="space-between" alignItems="center">
663 <Text dimColor>{tr().scoreLine(game.score, game.best)}</Text>
664 {/* 声音开关:只有一个音符图标,静音时变暗 */}
665 <Button
666 key="mute"
667 label="♪"
668 plain
669 dimColor={muted}
670 onPress={() => {
671 muted = !muted
672 void $.store.set('muted', muted)
673 void $.state.set(TICK, Date.now())
674 }}
675 />
676 </Box>
677 </Box>
678 )
679 } catch (err) {
680 const { Text } = $.ui.resolve(e)
681 return <Text color="#E63946">hop: {String(err).slice(0, 400)}</Text>
682 }
683 })
684}
685hooks/facts.ts 121 lines1// 方块上的冷知识卡片。完整题库在 data/facts.json(启动时读入),这里的内置几条是读不到题库时的后备。
2// 轮播:玩家真正「落到过」的冷知识记为看过;新开一局时从没看过的里面随机排一队,全看完才重来。
3
4// surface:这条只在哪个端成立;all 或不写就是通用
5export type Fact = { id: string; tag: { zh: string; en: string }; zh: string; en: string; surface?: string }
6
7export const BUILTIN: Fact[] = [
8 {
9 id: 'base-01',
10 tag: { zh: '快捷键', en: 'Shortcut' },
11 zh: '连按两下 Esc,可以回到之前的任意一条消息,改一改再重来。',
12 en: 'Press Esc twice to jump back to any earlier message, edit it, and try again.',
13 },
14 {
15 id: 'base-02',
16 tag: { zh: '快捷键', en: 'Shortcut' },
17 zh: 'Shift+Tab 在几种权限模式之间切换:默认、自动接受编辑、计划模式(只想不改)。',
18 en: 'Shift+Tab cycles permission modes: default, auto-accept edits, and plan mode (think, no edits).',
19 },
20 {
21 id: 'base-03',
22 tag: { zh: '记忆', en: 'Memory' },
23 zh: 'CLAUDE.md 是项目的长期记忆,每次启动都会读。不知道怎么写?跑一下 /init 让它自己生成。',
24 en: 'CLAUDE.md is the project memory, read on every start. Run /init to have Claude draft one for you.',
25 },
26 {
27 id: 'base-04',
28 tag: { zh: '小技巧', en: 'Tip' },
29 zh: '输入框里以 ! 开头,直接跑一条 shell 命令,命令和输出都会进到对话里。',
30 en: 'Start a prompt with ! to run a shell command; the command and its output land in the conversation.',
31 },
32 {
33 id: 'base-05',
34 tag: { zh: '小技巧', en: 'Tip' },
35 zh: '输入 @ 加文件路径,把文件直接带进对话,不用让 Claude 再去找。',
36 en: 'Type @ plus a file path to pull that file straight into the conversation.',
37 },
38 {
39 id: 'base-06',
40 tag: { zh: '命令行', en: 'CLI' },
41 zh: 'claude -p "问题" 是无头模式:不进交互界面,答完就退出,适合塞进脚本和 CI。',
42 en: 'claude -p "question" runs headless: no UI, answers and exits. Great for scripts and CI.',
43 },
44 {
45 id: 'base-07',
46 tag: { zh: '命令行', en: 'CLI' },
47 zh: 'claude -c 接着上一次的会话聊;claude --resume 可以从历史会话里挑一个继续。',
48 en: 'claude -c continues your last session; claude --resume lets you pick any past one.',
49 },
50 {
51 id: 'base-08',
52 tag: { zh: '上下文', en: 'Context' },
53 zh: '/compact 把前面的对话压缩成摘要腾出上下文,后面可以跟一句话告诉它重点保留什么。',
54 en: '/compact summarizes the conversation to free context. Add a note to say what to keep.',
55 },
56 {
57 id: 'base-09',
58 tag: { zh: '子 agent', en: 'Subagents' },
59 zh: '子 agent 有独立的上下文窗口。派它去翻代码,翻过的几十个文件不会塞满主对话。',
60 en: 'Subagents get their own context window, so the dozens of files they read stay out of yours.',
61 },
62 {
63 id: 'base-10',
64 tag: { zh: 'Hooks', en: 'Hooks' },
65 zh: 'Hooks 能在固定时机自动跑命令,比如每次改完文件就跑格式化,不用每次提醒 Claude。',
66 en: 'Hooks run commands at fixed moments, like formatting after every edit, so you never have to ask.',
67 },
68 {
69 id: 'base-11',
70 tag: { zh: 'Skills', en: 'Skills' },
71 zh: 'Skill 平时只露出简介,用得上时才读全文,所以装很多也不占上下文。',
72 en: 'Skills show only their description until needed, so installing many barely costs context.',
73 },
74 {
75 id: 'base-12',
76 tag: { zh: '历史', en: 'History' },
77 zh: 'MCP 是 Anthropic 在 2024 年 11 月开源的协议,让模型用统一的方式接外部工具和数据。',
78 en: 'Anthropic open-sourced MCP in November 2024: one standard way for models to reach tools and data.',
79 },
80 {
81 id: 'base-13',
82 tag: { zh: '历史', en: 'History' },
83 zh: 'Claude Code 最早在 2025 年 2 月和 Claude 3.7 Sonnet 一起,以研究预览的身份发布。',
84 en: 'Claude Code first shipped in February 2025 as a research preview alongside Claude 3.7 Sonnet.',
85 },
86 {
87 id: 'base-14',
88 tag: { zh: 'Mods', en: 'Mods' },
89 zh: '你正在玩的蹦一蹦就是一个 Mod:挂在 Claude Code 的事件上,自己画面板、自己算动画。',
90 en: 'This game is a Mod: it hooks into Claude Code events, draws its own pane and animates itself.',
91 },
92]
93
94let all: Fact[] = BUILTIN
95const byId = new Map(BUILTIN.map(f => [f.id, f]))
96
97export function setFacts(list: Fact[]): void {
98 if (list.length === 0) return
99 all = list
100 byId.clear()
101 for (const f of list) byId.set(f.id, f)
102}
103
104export const factById = (id: string | null): Fact | null => (typeof id === 'string' ? (byId.get(id) ?? null) : null)
105
106// 新开一局用的队列:没看过的随机排;全看过了就重新开始一轮
107export function queueFor(seen: readonly string[], seed: number): string[] {
108 const seenSet = new Set(seen)
109 let pool = all.filter(f => !seenSet.has(f.id)).map(f => f.id)
110 if (pool.length === 0) pool = all.map(f => f.id)
111 let s = seed >>> 0
112 for (let i = pool.length - 1; i > 0; i--) {
113 s = (Math.imul(s, 1664525) + 1013904223) >>> 0
114 const j = s % (i + 1)
115 ;[pool[i], pool[j]] = [pool[j], pool[i]]
116 }
117 return pool
118}
119
120export const factCount = (): number => all.length
121hooks/game.ts 149 lines1import type { Block, BlockKind, Game, Jump } from '../types'
2import { tr } from './i18n'
3
4// 按住 1 毫秒能跳多远(地面单位)
5export const SPEED = 0.09
6// 最多蓄力这么久
7export const MAX_HOLD_MS = 2600
8// 按住时方块最多压到原高度的这么多
9export const PRESS_MIN = 0.62
10
11const PLAIN: string[] = ['#E9C46A', '#F4A261', '#8AB17D', '#7FB3D5', '#C9ADA7', '#F2E8CF', '#B8A1D9', '#E5989B']
12
13// 特殊方块:落上去有额外加分
14export const SPECIAL: Record<Exclude<BlockKind, 'cube' | 'disk'>, { bonus: number }> = {
15 terminal: { bonus: 3 },
16 coffee: { bonus: 3 },
17 test: { bonus: 5 },
18 git: { bonus: 5 },
19}
20
21// 可复现的随机数:种子存在局面里,热重载后接着用
22export function rand(seed: number): [number, number] {
23 let t = (seed + 0x6d2b79f5) >>> 0
24 let r = Math.imul(t ^ (t >>> 15), t | 1)
25 r ^= r + Math.imul(r ^ (r >>> 7), r | 61)
26 return [((r ^ (r >>> 14)) >>> 0) / 4294967296, t]
27}
28
29function nextBlock(game: Game, from: Block): Block {
30 let seed = game.seed
31 const roll = (): number => {
32 const [v, s] = rand(seed)
33 seed = s
34 return v
35 }
36 // 难度按跳了几块来算(不按分数,连击加分太快会让难度跳着涨):
37 // 前 3 跳是热身大块;之后到第 45 跳左右,方块从 27 缩到 13、间距越拉越大;
38 // 过了一半,偶尔冒出特别小的块(半径 10),跳中允许的按键误差约 ±0.1 秒
39 const level = Math.min(game.jumps, 45) / 45
40 const isWarmup = game.jumps < 3
41 const isTiny = !isWarmup && level > 0.5 && roll() < 0.12 + 0.1 * level
42 const half = isWarmup
43 ? 29 + roll() * 3
44 : isTiny
45 ? 10 + roll() * 1.5
46 : Math.max(11, 27 - level * 14 + (roll() - 0.5) * 7)
47 const gap = isWarmup ? 8 + roll() * 10 : 10 + roll() * (20 + level * 60)
48 const dir = roll() < 0.5 ? 'x' : 'y'
49 const dist = from.half + half + gap
50 const special = roll()
51 let kind: BlockKind = roll() < 0.5 ? 'cube' : 'disk'
52 if (game.jumps > 1 && special < 0.14) {
53 kind = (['terminal', 'coffee', 'test', 'git'] as const)[Math.floor(roll() * 4)]
54 }
55 const color = PLAIN[Math.floor(roll() * PLAIN.length)]
56 game.seed = seed
57 const fact = game.factQueue.shift() ?? null
58 return {
59 fact,
60 gx: from.gx + (dir === 'x' ? dist : 0),
61 gy: from.gy + (dir === 'y' ? dist : 0),
62 half,
63 height: 22 + Math.round(roll() * 8),
64 kind,
65 color,
66 dir,
67 }
68}
69
70export function newGame(seed: number, best: number, factQueue: string[] = []): Game {
71 const first: Block = { gx: 0, gy: 0, half: 28, height: 26, kind: 'cube', color: '#F2E8CF', dir: 'x', fact: null }
72 const game: Game = { blocks: [first], cur: 0, score: 0, combo: 0, best, seed, isOver: false, jumps: 0, at: null, lostBy: null, factQueue: [...factQueue], perfects: 0 }
73 game.blocks.push(nextBlock(game, first))
74 return game
75}
76
77// 一次起跳:算落点、结果和加分,返回新局面(不改旧的)
78// 某点是否落在方块顶面上:方块按正方形算,圆柱按圆算
79function onTop(b: Block, gx: number, gy: number): boolean {
80 const dx = gx - b.gx
81 const dy = gy - b.gy
82 const isRound = b.kind === 'disk' || b.kind === 'coffee' || b.kind === 'git'
83 return isRound ? Math.hypot(dx, dy) <= b.half : Math.max(Math.abs(dx), Math.abs(dy)) <= b.half
84}
85
86// 一次起跳:从小人实际站的位置,朝下一块的中心跳,按住多久就跳多远
87export function jump(prev: Game, holdMs: number): { game: Game; jump: Jump } {
88 const game: Game = JSON.parse(JSON.stringify(prev)) as Game
89 const cur = game.blocks[game.cur]
90 const target = game.blocks[game.cur + 1]
91 const from = game.at ?? { gx: cur.gx, gy: cur.gy }
92 const ms = Math.max(0, Math.min(MAX_HOLD_MS, holdMs))
93 const dist = ms * SPEED
94 const vx = target.gx - from.gx
95 const vy = target.gy - from.gy
96 const len = Math.hypot(vx, vy) || 1
97 const land = { gx: from.gx + (vx / len) * dist, gy: from.gy + (vy / len) * dist }
98 const toCenter = Math.hypot(target.gx - land.gx, target.gy - land.gy)
99
100 const base: Omit<Jump, 'result' | 'isPerfect' | 'gained' | 'label' | 'landZ'> = {
101 n: game.jumps + 1,
102 from: { gx: from.gx, gy: from.gy, z: cur.height },
103 to: land,
104 dir: target.dir,
105 holdMs: ms,
106 }
107 game.jumps += 1
108
109 if (onTop(target, land.gx, land.gy)) {
110 const isPerfect = toCenter <= Math.max(4, target.half * 0.25)
111 game.combo = isPerfect ? game.combo + 1 : 0
112 if (isPerfect) game.perfects += 1
113 let gained = isPerfect ? 2 * game.combo : 1
114 const t = tr()
115 let label = isPerfect ? (game.combo > 1 ? t.perfectN(game.combo) : t.perfect) : ''
116 if (target.kind in SPECIAL) {
117 const kind = target.kind as keyof typeof SPECIAL
118 gained += SPECIAL[kind].bonus
119 label = label ? `${label} · ${t[kind]}` : t[kind]
120 }
121 game.score += gained
122 game.best = Math.max(game.best, game.score)
123 game.cur += 1
124 game.at = land
125 game.blocks.push(nextBlock(game, target))
126 // 只留身后几块当风景
127 if (game.cur > 4) {
128 game.blocks = game.blocks.slice(game.cur - 4)
129 game.cur = 4
130 }
131 return { game, jump: { ...base, result: 'land', isPerfect, gained, label, landZ: target.height } }
132 }
133
134 // 跳得太近,还落在原来那块上:跟跳一跳一样,原地踏步也算输
135 if (onTop(cur, land.gx, land.gy)) {
136 game.at = land
137 game.isOver = true
138 game.lostBy = 'stay'
139 game.combo = 0
140 return { game, jump: { ...base, result: 'stay', isPerfect: false, gained: 0, label: '', landZ: cur.height } }
141 }
142
143 game.at = land
144 game.isOver = true
145 game.lostBy = 'fall'
146 game.combo = 0
147 return { game, jump: { ...base, result: 'fall', isPerfect: false, gained: 0, label: '', landZ: 0 } }
148}
149hooks/i18n.ts 119 lines1// 中英双语:默认跟随系统语言(macOS 的 AppleLanguages 第一项),中文用 zh,其余一律 en
2
3export type Lang = 'zh' | 'en'
4
5const STRINGS = {
6 zh: {
7 command: '蹦一蹦:等 Claude 的时候跳几下',
8 opened: '开跳:点面板下面的框,按住空格蓄力,松开起跳。',
9 notOpened: '面板没能打开:',
10 toastDone: 'Claude 干完了,回来看看吧',
11 loading: '正在摆方块……',
12 desktopOnly: '蹦一蹦需要在 Claude 桌面端里玩。',
13 placeholder: '点这里,按住空格',
14 hintIdle: '点左边的框,按住空格蓄力,松开起跳',
15 held: (s: string) => `按了 ${s} 秒`,
16 restart: '再来一局',
17 scoreLine: (score: number, best: number) => `本局 ${score} · 最高 ${best}`,
18 sceneHint: '按住空格蓄力,松开起跳',
19 best: (n: number) => `最高 ${n}`,
20 fell: '掉下去了',
21 stayed: '原地踏步也算输',
22 record: '新纪录!',
23 perfect: '完美',
24 perfectN: (n: number) => `完美 ×${n}`,
25 terminal: '终端方块 +3',
26 coffee: '咖啡方块 +3',
27 test: '测试方块 +5',
28 git: 'Git 方块 +5',
29 specialHint: '特殊方块有额外加分:终端、咖啡 +3,测试、Git +5',
30 you: '你',
31 lbInvite: '加入排行榜,和大家比一比?只上传昵称和成绩,随时可以退出。',
32 lbNick: '起个昵称,回车加入',
33 lbTabs: { today: '今日', week: '本周', all: '总榜' } as Record<string, string>,
34 lbMe: (rank: number, score: number) => `你:第 ${rank} 名(${score} 分)`,
35 lbNotRanked: '你还没上榜,打一局试试',
36 lbEmpty: '榜上还没人',
37 lbLeave: '退出排行榜',
38 lbLeft: '已退出,数据已删除',
39 lbOffline: '排行榜暂时连不上',
40 lbLoading: '正在加载排行榜……',
41 lbBusy: '加载中…',
42 lbSlow: '网络有点慢,还在加载…',
43 lbRetry: '重试',
44 lbStale: ',下面是上次的结果',
45 lbBadNick: '这个昵称不能用,换一个吧',
46 lbTooFast: '太快了,稍后再试',
47 surface: { cli: '终端', desktop: '桌面端', ide: 'IDE', web: '网页端', mobile: '手机端' } as Record<string, string>,
48 bandBusy: 'Claude 还在干活',
49 bandPlay: '蹦一蹦',
50 bandMute: '本会话不再提示',
51 needsYou: 'Claude 在等你',
52 doneNow: 'Claude 干完了',
53 backToClaude: '回到 Claude',
54 },
55 en: {
56 command: 'Hop: a little game to play while Claude works',
57 opened: 'Click the box under the game, hold Space to charge, release to jump.',
58 notOpened: 'Could not open the pane: ',
59 toastDone: 'Claude is done. Come take a look',
60 loading: 'Setting up the blocks…',
61 desktopOnly: 'Hop runs in the Claude desktop app.',
62 placeholder: 'Click, hold Space',
63 hintIdle: 'Click the box, hold Space to charge, release to jump',
64 held: (s: string) => `Held ${s}s`,
65 restart: 'Play again',
66 scoreLine: (score: number, best: number) => `Score ${score} · Best ${best}`,
67 sceneHint: 'Hold Space to charge, release to jump',
68 best: (n: number) => `Best ${n}`,
69 fell: 'You fell off',
70 stayed: 'No standing still',
71 record: 'New record!',
72 perfect: 'Perfect',
73 perfectN: (n: number) => `Perfect ×${n}`,
74 terminal: 'Terminal block +3',
75 coffee: 'Coffee block +3',
76 test: 'Test block +5',
77 git: 'Git block +5',
78 specialHint: 'Special blocks give bonus points: terminal and coffee +3, test and Git +5',
79 you: 'You',
80 lbInvite: 'Join the leaderboard? Only your nickname and scores are sent. Leave any time.',
81 lbNick: 'Pick a nickname, press Enter',
82 lbTabs: { today: 'Today', week: 'Week', all: 'All time' } as Record<string, string>,
83 lbMe: (rank: number, score: number) => `You: #${rank} (${score})`,
84 lbNotRanked: 'Not ranked yet. Play a round',
85 lbEmpty: 'No one here yet',
86 lbLeave: 'Leave leaderboard',
87 lbLeft: 'Left. Your data was deleted',
88 lbOffline: 'Leaderboard is unreachable right now',
89 lbLoading: 'Loading leaderboard…',
90 lbBusy: 'Loading…',
91 lbSlow: 'Slow network, still loading…',
92 lbRetry: 'Retry',
93 lbStale: '. Showing the last results',
94 lbBadNick: 'That nickname is not allowed. Try another',
95 lbTooFast: 'Too fast. Try again in a moment',
96 surface: { cli: 'Terminal', desktop: 'Desktop', ide: 'IDE', web: 'Web', mobile: 'Mobile' } as Record<string, string>,
97 bandBusy: 'Claude is still working',
98 bandPlay: 'Play Hop',
99 bandMute: 'Not this session',
100 needsYou: 'Claude needs you',
101 doneNow: 'Claude is done',
102 backToClaude: 'Back to Claude',
103 },
104}
105
106let current: Lang = 'zh'
107
108export const setLang = (l: Lang): void => {
109 current = l
110}
111export const lang = (): Lang => current
112export const tr = () => STRINGS[current]
113
114// 从 `defaults read -g AppleLanguages` 的输出里取第一项
115export function langFromAppleLanguages(out: string): Lang {
116 const first = out.match(/"?([A-Za-z]{2,3})[-_A-Za-z]*"?/)?.[1]?.toLowerCase() ?? ''
117 return first === 'zh' ? 'zh' : 'en'
118}
119hooks/scene.ts 536 lines1import type { Block, Game, Jump } from '../types'
2import { factById } from './facts'
3import { MAX_HOLD_MS, PRESS_MIN } from './game'
4import { lang, tr } from './i18n'
5
6// 画面:等距视角
7// 画布大小(像素):默认 440×360;面板放大时调大画布、看到更远的地方,方块和小人保持原大小
8export const BASE_W = 440
9export const BASE_H = 360
10export let W = BASE_W
11export let H = BASE_H
12export function setCanvas(w: number, h: number): void {
13 W = Math.max(BASE_W, Math.round(w))
14 H = Math.max(BASE_H, Math.round(h))
15}
16// 世界整体放大多少
17const ZOOM = 1.3
18const FONT = `-apple-system, 'PingFang SC', 'Helvetica Neue', sans-serif`
19
20type Pt = [number, number]
21
22// 地面坐标 → 屏幕坐标:+gx 往右上,+gy 往左上
23function P(gx: number, gy: number, z = 0): Pt {
24 return [(gx - gy) * 0.866, -(gx + gy) * 0.5 - z]
25}
26
27function cam(game: Game): Pt {
28 const a = game.blocks[game.cur]
29 const b = game.blocks[game.cur + 1] ?? a
30 const [x1, y1] = P(a.gx, a.gy, a.height)
31 const [x2, y2] = P(b.gx, b.gy, b.height)
32 return [W / 2 - (ZOOM * (x1 + x2)) / 2, H * 0.6 - (ZOOM * (y1 + y2)) / 2]
33}
34
35function shade(hex: string, k: number): string {
36 const n = parseInt(hex.slice(1), 16)
37 const f = (c: number) => Math.max(0, Math.min(255, Math.round(c * k)))
38 const r = f(n >> 16)
39 const g = f((n >> 8) & 255)
40 const b = f(n & 255)
41 return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, '0')}`
42}
43
44const pts = (list: Pt[]) => list.map(([x, y]) => `${x.toFixed(1)},${y.toFixed(1)}`).join(' ')
45
46function cube(b: Block, color: string): string {
47 const { gx: x, gy: y, half: w, height: h } = b
48 const A0 = P(x - w, y - w), B0 = P(x + w, y - w), D0 = P(x - w, y + w)
49 const A = P(x - w, y - w, h), B = P(x + w, y - w, h), C = P(x + w, y + w, h), D = P(x - w, y + w, h)
50 return `<polygon points="${pts([A, B, B0, A0])}" fill="${shade(color, 0.72)}"/>
51 <polygon points="${pts([A, D, D0, A0])}" fill="${shade(color, 0.86)}"/>
52 <polygon points="${pts([A, B, C, D])}" fill="${color}"/>
53 <polyline points="${pts([D, A, B])}" fill="none" stroke="#fff" stroke-opacity="0.35" stroke-width="1"/>`
54}
55
56function disk(b: Block, color: string): string {
57 const [cx, cy] = P(b.gx, b.gy, b.height)
58 const rx = b.half * 1.2247
59 const ry = b.half * 0.7071
60 const h = b.height
61 return `<path d="M${cx - rx} ${cy} L${cx - rx} ${cy + h} A${rx} ${ry} 0 0 0 ${cx + rx} ${cy + h} L${cx + rx} ${cy} Z" fill="${shade(color, 0.78)}"/>
62 <ellipse cx="${cx}" cy="${cy}" rx="${rx}" ry="${ry}" fill="${color}"/>
63 <ellipse cx="${cx}" cy="${cy}" rx="${rx}" ry="${ry}" fill="none" stroke="#fff" stroke-opacity="0.35"/>`
64}
65
66// 方块顶面上的小图案
67function decal(b: Block): string {
68 const [cx, cy] = P(b.gx, b.gy, b.height)
69 const s = b.half / 26
70 const at = (body: string) =>
71 `<g transform="translate(${cx} ${cy}) scale(${s.toFixed(2)} ${(s * 0.62).toFixed(2)})">${body}</g>`
72 switch (b.kind) {
73 case 'terminal':
74 return at(`<text x="-13" y="6" font-family="Menlo, monospace" font-size="17" font-weight="700" fill="#7CFC9A">>_</text>`)
75 case 'test':
76 return at(`<path d="M-9 0 l6 6 l12 -13" stroke="#fff" stroke-width="5" fill="none" stroke-linecap="round" stroke-linejoin="round"/>`)
77 case 'git':
78 return at(`<g stroke="#fff" stroke-width="3" fill="none"><circle cx="-6" cy="8" r="3.5"/><circle cx="-6" cy="-9" r="3.5"/><circle cx="8" cy="-4" r="3.5"/><path d="M-6 4.5 v-10 M-6 4 q0 -8 14 -8"/></g>`)
79 case 'coffee':
80 // 杯口一缕热气:短、淡,只在杯口上方
81 return `<path d="M${cx} ${cy - 3} q-3 -4 0 -8 q3 -4 0 -8" fill="none" stroke="#fff" stroke-opacity="0.55" stroke-width="1.6" stroke-linecap="round"/>`
82 default:
83 return ''
84 }
85}
86
87// 带冷知识的方块顶面印一个小二维码:三个角是定位块,中间的点由冷知识 id 决定
88function qrBits(id: string): boolean[] {
89 let h = 2166136261
90 for (const c of id) h = Math.imul(h ^ c.charCodeAt(0), 16777619)
91 const bits: boolean[] = []
92 for (let i = 0; i < 49; i++) {
93 h = Math.imul(h ^ (h >>> 13), 1274126177)
94 bits.push(((h >>> 7) & 1) === 1)
95 }
96 return bits
97}
98
99const isFinder = (r: number, c: number) =>
100 (r < 3 && c < 3) || (r < 3 && c > 3) || (r > 3 && c < 3)
101
102function qrCell(r: number, c: number, bits: boolean[]): boolean {
103 if (isFinder(r, c)) return true
104 if (r === 3 || c === 3) return bits[r * 7 + c] && (r + c) % 2 === 0
105 return bits[r * 7 + c]
106}
107
108function qr(b: Block, color: string): string {
109 if (typeof b.fact !== 'string' || !b.fact) return ''
110 const bits = qrBits(b.fact)
111 const span = b.half * (b.kind === 'cube' ? 1.05 : 0.95)
112 const cell = span / 7
113 const x0 = b.gx - span / 2
114 const y0 = b.gy - span / 2
115 const z = b.height + 0.2
116 let out = ''
117 for (let r = 0; r < 7; r++) {
118 for (let c = 0; c < 7; c++) {
119 if (!qrCell(r, c, bits)) continue
120 const gx = x0 + c * cell
121 const gy = y0 + r * cell
122 out += `<polygon points="${pts([P(gx, gy, z), P(gx + cell, gy, z), P(gx + cell, gy + cell, z), P(gx, gy + cell, z)])}"/>`
123 }
124 }
125 return `<g fill="${shade(color, 0.55)}" opacity="0.5">${out}</g>`
126}
127
128const KIND_COLOR: Record<string, string> = { terminal: '#2B2D31', coffee: '#7A5236', test: '#4FA35B', git: '#E8603C' }
129
130function block(b: Block, attrs = '', anim = ''): string {
131 const color = KIND_COLOR[b.kind] ?? b.color
132 const isRound = b.kind === 'disk' || b.kind === 'coffee' || b.kind === 'git'
133 const [sx, sy] = P(b.gx, b.gy)
134 const shadow = `<ellipse cx="${sx + 10}" cy="${sy + 4}" rx="${b.half * 1.5}" ry="${b.half * 0.6}" fill="#5B4636" opacity="0.12"/>`
135 const mark = b.kind === 'cube' || b.kind === 'disk' ? qr(b, color) : ''
136 return `<g${attrs}>${anim}${shadow}${isRound ? disk(b, color) : cube(b, color)}${mark}${decal(b)}</g>`
137}
138
139
140// 画面一律是不带动画的静态图:桌面端把它当普通图片显示,换内容时不会闪。
141// 动画由插件按时间一帧一帧算出来(每秒约 30 帧)。
142
143// 主角:一只橙色的小方块怪,原点在脚底中心。pose 控制小动作
144type Pose = { blink: number; armL: number; armR: number; look: number }
145const REST: Pose = { blink: 0, armL: 0, armR: 0, look: 0 }
146
147// 主角皮肤:Claude Code 里是橙色小方块怪,DeepSeek Harness 里换成蓝色小鲸鱼
148export type Skin = 'clawd' | 'whale'
149let SKIN: Skin = 'clawd'
150export function setSkin(s: Skin): void {
151 SKIN = s
152}
153
154function heroArt(p: Pose): string {
155 return SKIN === 'whale' ? whaleArt(p) : clawdArt(p)
156}
157
158// 小鲸鱼:身子趴着,尾巴翘在后面;挥手换成头顶喷水,手换成两片小鳍
159function whaleArt(p: Pose): string {
160 const eyeH = Math.max(0.7, 4 * (1 - p.blink))
161 const eyeY = -14 + (4 - eyeH) / 2
162 const spout = Math.max(0, p.armR)
163 const jet = spout > 0.3
164 ? `<rect x="2" y="${f1(-21 - spout * 1.6)}" width="2" height="${f1(spout * 1.6)}" fill="#9FB4FF"/><rect x="-1" y="${f1(-22 - spout * 1.8)}" width="2" height="2" fill="#9FB4FF"/><rect x="5" y="${f1(-22 - spout * 1.8)}" width="2" height="2" fill="#9FB4FF"/>`
165 : ''
166 return `${jet}
167 <rect x="-19" y="-21" width="5" height="4" fill="#3A56D8"/><rect x="-13" y="-19" width="4" height="4" fill="#3A56D8"/><rect x="-16" y="-17" width="4" height="5" fill="#3A56D8"/>
168 <rect x="-13" y="-19" width="26" height="18" rx="6" fill="#4D6BFE"/>
169 <rect x="-9" y="-7" width="20" height="5" rx="2" fill="#DCE4FF"/>
170 <rect x="-3" y="${f1(-6 - p.armL)}" width="5" height="3" fill="#3A56D8"/><rect x="6" y="${f1(-6 - p.armL)}" width="5" height="3" fill="#3A56D8"/>
171 <rect x="${f1(4 + p.look)}" y="${f1(eyeY)}" width="3" height="${f1(eyeH)}" fill="#1B1F3B"/>
172 <rect x="${f1(9 + p.look * 0.5)}" y="-9" width="2" height="1" fill="#F3A6B5"/>`
173}
174
175function clawdArt(p: Pose): string {
176 const eyeH = Math.max(0.7, 5 * (1 - p.blink))
177 const eyeY = -19 + (5 - eyeH) / 2
178 return `
179 <rect x="-9" y="-6" width="3" height="6" fill="#B85C3E"/><rect x="-3" y="-6" width="3" height="6" fill="#B85C3E"/>
180 <rect x="2" y="-6" width="3" height="6" fill="#B85C3E"/><rect x="7" y="-6" width="3" height="6" fill="#B85C3E"/>
181 <rect x="-13" y="${f1(-17 - p.armL)}" width="4" height="5" fill="#D97757"/><rect x="10" y="${f1(-17 - p.armR)}" width="4" height="5" fill="#D97757"/>
182 <rect x="-10" y="-24" width="21" height="18" rx="2" fill="#D97757"/>
183 <rect x="${f1(-5 + p.look)}" y="${f1(eyeY)}" width="3" height="${f1(eyeH)}" fill="#2B2B2B"/><rect x="${f1(4 + p.look)}" y="${f1(eyeY)}" width="3" height="${f1(eyeH)}" fill="#2B2B2B"/>`
184}
185
186// 待机小动作:呼吸、每隔几秒眨一下眼、偶尔挥挥手、眼睛看向下一块
187function idlePose(ms: number, look: number): { pose: Pose; breathe: number } {
188 const blinkT = ms % BLINK_EVERY
189 const blink = blinkT < 140 ? Math.sin((blinkT / 140) * Math.PI) : 0
190 const waveT = ms % WAVE_EVERY
191 const wave = waveT < 1000 ? Math.abs(Math.sin((waveT / 1000) * Math.PI * 3)) * 5 * Math.sin((waveT / 1000) * Math.PI) : 0
192 return { pose: { blink, armL: 0, armR: wave, look }, breathe: 0 }
193}
194
195// 待机时只有眨眼、挥手那一下需要重画;其余时间画面不动,面板也就不用重画
196const BLINK_EVERY = 3400
197const WAVE_EVERY = 7300
198export function idleMoving(ms: number): boolean {
199 return ms % BLINK_EVERY < 140 + 60 || ms % WAVE_EVERY < 1000 + 60
200}
201
202const background = () => `<defs>
203 <linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
204 <stop offset="0" stop-color="#FBF3E7"/><stop offset="1" stop-color="#EBDDCB"/>
205 </linearGradient>
206 </defs>
207 <rect width="${W}" height="${H}" fill="url(#sky)"/>
208 <g fill="#fff" opacity="0.5">
209 <circle cx="${W * 0.14}" cy="${H * 0.19}" r="2"/><circle cx="${W * 0.86}" cy="${H * 0.14}" r="1.6"/><circle cx="${W * 0.75}" cy="${H * 0.33}" r="1.2"/><circle cx="${W * 0.25}" cy="${H * 0.42}" r="1.4"/>
210 </g>`
211
212// 起跳各段的时长(秒)
213const FLY = 0.45
214const PAN_AT = FLY + 0.08
215const PAN = 0.35
216const DROP_AT = FLY + 0.2
217const DROP = 0.3
218const POP = 1.0
219// 新卡片等加分飘字散了再淡入
220// 新卡片落地就淡入(加分飘字画在它上面、带白底,不会互相看不清)
221const CARD_AT = FLY
222const CARD_FADE = 0.15
223// 一跳从松手到画面完全停下要多久(毫秒)
224export const ANIM_MS = Math.round((Math.max(PAN_AT + PAN, DROP_AT + DROP, FLY + POP, CARD_AT + CARD_FADE) + 0.05) * 1000)
225
226const clamp01 = (v: number) => Math.max(0, Math.min(1, v))
227const lerp = (a: number, b: number, k: number) => a + (b - a) * k
228const easeInOut = (k: number) => (k < 0.5 ? 2 * k * k : 1 - Math.pow(-2 * k + 2, 2) / 2)
229const easeOut = (k: number) => 1 - Math.pow(1 - k, 3)
230const f1 = (v: number) => v.toFixed(1)
231
232function mix(a: string, b: string, k: number): string {
233 const pa = parseInt(a.slice(1), 16)
234 const pb = parseInt(b.slice(1), 16)
235 const c = [16, 8, 0].map(sh => Math.round(lerp((pa >> sh) & 255, (pb >> sh) & 255, k)))
236 return `#${((c[0] << 16) | (c[1] << 8) | c[2]).toString(16).padStart(6, '0')}`
237}
238
239// 黄 → 橙 → 红
240const heat = (k: number) => (k < 0.5 ? mix('#E9C46A', '#F4A261', k * 2) : mix('#F4A261', '#E63946', (k - 0.5) * 2))
241
242function hud(game: Game): string {
243 return `<g font-family="${FONT}">
244 <text x="22" y="52" font-size="40" font-weight="800" fill="#3D2E22">${game.score}</text>
245 <text x="24" y="74" font-size="12" fill="#8C7A6B">${tr().best(game.best)}</text>
246 </g>`
247}
248
249function hint(text: string): string {
250 return `<text x="${W / 2}" y="${H - 18}" text-anchor="middle" font-family="${FONT}" font-size="13" fill="#8C7A6B">${text}</text>`
251}
252
253function overScreen(game: Game, alpha: number): string {
254 if (alpha <= 0) return ''
255 return `<g opacity="${alpha.toFixed(2)}" font-family="${FONT}" text-anchor="middle">
256 <rect width="${W}" height="${H}" fill="#2B2018" opacity="0.55"/>
257 ${game.lostBy === 'stay' ? '' : `<text x="${W / 2}" y="${H / 2 - 30}" font-size="15" fill="#F2E8CF">${tr().fell}</text>`}
258 <text x="${W / 2}" y="${H / 2 + 20}" font-size="56" font-weight="800" fill="#fff">${game.score}</text>
259 <text x="${W / 2}" y="${H / 2 + 48}" font-size="13" fill="#F2E8CF">${game.score >= game.best && game.score > 0 ? tr().record : tr().best(game.best)}</text>
260 </g>`
261}
262
263// 方块竖向压缩:以底面中心为原点,sy 是高度比例
264function squashed(b: Block, sy: number, extra = ''): string {
265 if (Math.abs(sy - 1) < 0.001 && !extra) return block(b)
266 const [bx, by] = P(b.gx, b.gy)
267 return `<g${extra} transform="translate(${f1(bx)} ${f1(by)}) scale(1 ${sy.toFixed(3)}) translate(${f1(-bx)} ${f1(-by)})">${block(b)}</g>`
268}
269
270// 主角:站在 (x, y),scale 压扁,rot 空翻角度,alpha 透明度
271function hero(x: number, y: number, sx = 1, sy = 1, rot = 0, alpha = 1, pivotY = -12, pose: Pose = REST): string {
272 const shadow = rot === 0 ? `<ellipse cx="0" cy="1" rx="11" ry="4" fill="#000" opacity="0.18"/>` : ''
273 return `<g transform="translate(${f1(x)} ${f1(y)})" opacity="${alpha.toFixed(2)}">${shadow}<g transform="rotate(${f1(rot)} 0 ${pivotY}) scale(${sx.toFixed(3)} ${sy.toFixed(3)})">${heroArt(pose)}</g></g>`
274}
275
276// 摔在地上:落点处躺倒,dir 决定往哪边倒
277function fallen(gx: number, gy: number, dir: 'x' | 'y', k = 1): string {
278 const [x, y] = P(gx, gy, 0)
279 return hero(x, y, 1, 1, (dir === 'x' ? 90 : -90) * k, 1, 0, { blink: 1, armL: 0, armR: 0, look: 0 })
280}
281
282// 按地面深度把主角插进方块之间:比它远的先画,比它近的后画,近处的方块会挡住它
283function layered(blocks: Block[], depth: number, me: string, draw: (b: Block) => string = b => block(b)): string {
284 const sorted = byDepth(blocks)
285 return `${sorted.filter(b => b.gx + b.gy >= depth).map(draw).join('')}${me}${sorted.filter(b => b.gx + b.gy < depth).map(draw).join('')}`
286}
287
288function frame(camera: Pt, world: string, over: string, tag: string): string {
289 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}"><!--${tag}-->
290 ${background()}
291 <g transform="translate(${f1(camera[0])} ${f1(camera[1])}) scale(${ZOOM})">${world}</g>
292 ${over}
293 </svg>`
294}
295
296const esc = (t: string) => t.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
297
298// 按显示宽度折行(中文一个字算 2,英文和数字算 1):
299// 英文单词、数字、版本号、命令这类连续的半角串不拆开;逗号句号、右引号右括号不放行首;左引号左括号不留行尾
300const NO_START = new Set([...'。,、;:!?)」』》〉】.,;:!?)'])
301const NO_END = new Set([...'(「『《〈【('])
302const widthOf = (t: string) => [...t].reduce((a, ch) => a + (ch.charCodeAt(0) > 0x2e80 ? 2 : 1), 0)
303
304export function wrap(text: string, max: number): string[] {
305 const tokens = text.match(/[A-Za-z0-9_.+#/@:'%$~=-]+|\s+|./gsu) ?? []
306 const lines: string[] = []
307 let line = ''
308 let w = 0
309 const flush = () => {
310 if (line.trim()) lines.push(line.trim())
311 line = ''
312 w = 0
313 }
314 for (const tok of tokens) {
315 const tw = widthOf(tok)
316 if (/^\s+$/.test(tok)) {
317 if (w > 0 && w + 1 <= max) {
318 line += ' '
319 w += 1
320 }
321 continue
322 }
323 if (w + tw > max && line.trim()) {
324 // 标点不放行首:挂在上一行末尾,宁可略微超出
325 if (NO_START.has(tok)) {
326 line += tok
327 w += tw
328 continue
329 }
330 // 左引号、左括号不留在行尾:带到下一行
331 const last = [...line].pop() ?? ''
332 if (NO_END.has(last)) {
333 line = line.slice(0, -last.length)
334 flush()
335 line = last
336 w = widthOf(last)
337 } else {
338 flush()
339 }
340 }
341 // 单个半角串比一整行还长(很长的路径、网址),只好硬切
342 if (tw > max) {
343 let rest = tok
344 while (widthOf(rest) > max - w) {
345 const cut = Math.max(1, max - w)
346 line += rest.slice(0, cut)
347 rest = rest.slice(cut)
348 flush()
349 }
350 line += rest
351 w += widthOf(rest)
352 continue
353 }
354 line += tok
355 w += tw
356 }
357 flush()
358 return lines
359}
360
361const CARD_W = 200
362
363// 卡片放在下一块的反方向那一侧,避免挡住要跳的地方;anchor 是方块顶面中心的屏幕坐标
364function factCard(id: string | null, anchor: Pt, side: 'left' | 'right', alpha: number): string {
365 const f = factById(id)
366 if (!f || alpha <= 0) return ''
367 const l = lang()
368 const lines = wrap(f[l], 29).slice(0, 6)
369 const h = 40 + lines.length * 17
370 // 卡片放在方块旁边(下一块的反方向),超出画布就往里收;左上角留给分数
371 const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v))
372 const x = clamp(side === 'left' ? anchor[0] - CARD_W - 50 : anchor[0] + 50, 12, W - CARD_W - 12)
373 const top = x < 150 ? 88 : 14
374 const y = clamp(anchor[1] - h - 60, top, H - h - 12)
375 const ex = side === 'left' ? x + CARD_W : x
376 const ey = y + h - 12
377 const bits = qrBits(f.id)
378 let icon = ''
379 for (let r = 0; r < 7; r++) for (let c = 0; c < 7; c++) if (qrCell(r, c, bits)) icon += `<rect x="${x + 12 + c * 2}" y="${y + 11 + r * 2}" width="2" height="2"/>`
380 return `<g opacity="${alpha.toFixed(2)}" font-family="${FONT}">
381 <line x1="${f1(ex)}" y1="${f1(ey)}" x2="${f1(anchor[0])}" y2="${f1(anchor[1])}" stroke="#D97757" stroke-width="1.2" stroke-dasharray="3 3" opacity="0.7"/>
382 <circle cx="${f1(anchor[0])}" cy="${f1(anchor[1])}" r="3" fill="#D97757"/>
383 <rect x="${x + 1}" y="${y + 2}" width="${CARD_W}" height="${h}" rx="10" fill="#5B4636" opacity="0.10"/>
384 <rect x="${x}" y="${y}" width="${CARD_W}" height="${h}" rx="10" fill="#FFFDF8" stroke="#EADBC8"/>
385 <g fill="#D97757">${icon}</g>
386 <text x="${x + 32}" y="${y + 23}" font-size="11.5" font-weight="700" fill="#D97757">${esc(f.tag[l])}${f.surface && f.surface !== 'all' && tr().surface[f.surface] ? `<tspan fill="#8C7A6B" font-weight="400"> · ${esc(tr().surface[f.surface])}</tspan>` : ''}</text>
387 ${lines.map((t, i) => `<text x="${x + 12}" y="${y + 44 + i * 17}" font-size="12" fill="#3D2E22">${esc(t)}</text>`).join('')}
388 </g>`
389}
390
391// 方块顶面中心在画面上的位置(含镜头)
392function topOnScreen(b: Block, camera: Pt): Pt {
393 const [x, y] = P(b.gx, b.gy, b.height)
394 return [camera[0] + x * ZOOM, camera[1] + y * ZOOM]
395}
396
397const sideFor = (game: Game): 'left' | 'right' => ((game.blocks[game.cur + 1] ?? game.blocks[game.cur]).dir === 'x' ? 'left' : 'right')
398
399const byDepth = (blocks: Block[]) => [...blocks].sort((a, b) => b.gx + b.gy - (a.gx + a.gy))
400
401// 静止:小人站在实际落点;掉下去了就盖上结算
402export function stillFrame(game: Game, ms = 0): string {
403 const cur = game.blocks[game.cur]
404 const pos = game.at ?? { gx: cur.gx, gy: cur.gy }
405 const target = game.blocks[game.cur + 1] ?? cur
406 const camera = cam(game)
407 let world: string
408 if (game.lostBy === 'fall') {
409 world = layered(game.blocks, pos.gx + pos.gy, fallen(pos.gx, pos.gy, target.dir))
410 } else {
411 const [hx, hy] = P(pos.gx, pos.gy, cur.height)
412 const { pose, breathe } = idlePose(ms, target.dir === 'x' ? 1.2 : -1.2)
413 world = layered(game.blocks, cur.gx + cur.gy, hero(hx, hy, 1 - breathe * 0.5, 1 + breathe, 0, 1, -12, pose))
414 }
415 const tip = game.isOver ? '' : game.jumps === 0 ? hint(tr().sceneHint) : ''
416 const card = game.isOver ? '' : factCard(cur.fact, topOnScreen(cur, camera), sideFor(game), 1)
417 return frame(camera, world, `${hud(game)}${card}${tip}${overScreen(game, game.isOver ? 1 : 0)}`, `still ${game.jumps}`)
418}
419
420// 蓄力:脚下的方块被压下去,小人跟着下沉、稍微扁一点,脚下一圈蓄力环,底部蓄力条
421export function chargeFrame(game: Game, heldMs: number): string {
422 const k = clamp01(heldMs / MAX_HOLD_MS)
423 const cur = game.blocks[game.cur]
424 const pos = game.at ?? { gx: cur.gx, gy: cur.gy }
425 const sink = cur.height * (1 - PRESS_MIN) * k
426 const [hx, hy] = P(pos.gx, pos.gy, cur.height - sink)
427 const sorted = byDepth(game.blocks)
428 const far = sorted.filter(b => b.gx + b.gy >= cur.gx + cur.gy)
429 const near = sorted.filter(b => b.gx + b.gy < cur.gx + cur.gy)
430 const color = heat(k)
431 const ring = `<ellipse cx="${f1(hx)}" cy="${f1(hy)}" rx="${f1(lerp(8, cur.half * 1.2, k))}" ry="${f1(lerp(4, cur.half * 0.7, k))}" fill="none" stroke="${color}" stroke-width="2.5"/>`
432 const shake = Math.sin(heldMs / 35) * 0.8 * k
433 const pose: Pose = { blink: k > 0.6 ? 0.55 : 0, armL: -1 + shake, armR: -1 - shake, look: 0 }
434 const world = `${far.map(b => (b === cur ? squashed(b, 1 - (1 - PRESS_MIN) * k) : block(b))).join('')}${ring}${hero(hx, hy, 1 + 0.1 * k, 1 - 0.18 * k, 0, 1, -12, pose)}${near.map(b => block(b)).join('')}`
435 const bw = W - 140
436 const bar = `<rect x="70" y="${H - 30}" width="${bw}" height="10" rx="5" fill="#000" opacity="0.08"/>
437 <rect x="70" y="${H - 30}" width="${f1(bw * k)}" height="10" rx="5" fill="${color}"/>`
438 const camera = cam(game)
439 const card = factCard(cur.fact, topOnScreen(cur, camera), sideFor(game), 1)
440 return frame(camera, world, `${hud(game)}${card}${bar}`, `charge ${game.jumps}`)
441}
442
443// 起跳:抛物线 + 空翻 → 落地回弹 / 加分飘字 / 完美波纹 → 镜头跟过去、新方块落下;掉下去就往下掉再盖结算
444export function jumpFrame(before: Game, after: Game, j: Jump, ms: number, clockMs = 0): string {
445 const t = ms / 1000
446 const from = before.blocks[before.cur]
447 const target = before.blocks[before.cur + 1]
448 const isLand = j.result === 'land'
449 const pan = isLand ? easeInOut(clamp01((t - PAN_AT) / PAN)) : 0
450 const [ax, ay] = cam(before)
451 const [bx, by] = isLand ? cam(after) : [ax, ay]
452 const camera: Pt = [lerp(ax, bx, pan), lerp(ay, by, pan)]
453
454 // 原来那块弹回去
455 const c = 1 - (1 - PRESS_MIN) * clamp01(j.holdMs / MAX_HOLD_MS)
456 const sp = clamp01(t / 0.3)
457 const springY = sp < 0.4 ? lerp(c, 1.08, sp / 0.4) : sp < 0.7 ? lerp(1.08, 0.97, (sp - 0.4) / 0.3) : lerp(0.97, 1, (sp - 0.7) / 0.3)
458
459 // 小人位置
460 const [sx, sy] = P(j.from.gx, j.from.gy, j.from.z)
461 const endZ = j.result === 'fall' ? target.height : j.landZ
462 const [ex, ey] = P(j.to.gx, j.to.gy, endZ)
463 const peak = 46 + Math.hypot(ex - sx, ey - sy) * 0.35
464 const cx = (sx + ex) / 2
465 const cy = Math.min(sy, ey) - peak
466 const u = easeInOut(clamp01(t / FLY))
467 let hx = (1 - u) * (1 - u) * sx + 2 * (1 - u) * u * cx + u * u * ex
468 let hy = (1 - u) * (1 - u) * sy + 2 * (1 - u) * u * cy + u * u * ey
469 const rot = t < FLY ? (j.dir === 'x' ? 360 : -360) * u : 0
470 let hsx = 1
471 let hsy = 1
472 const after1 = t - FLY
473 if (after1 > 0 && (isLand || j.result === 'stay')) {
474 const q = clamp01(after1 / 0.3)
475 hsx = q < 0.35 ? lerp(1, 1.25, q / 0.35) : q < 0.7 ? lerp(1.25, 0.95, (q - 0.35) / 0.35) : lerp(0.95, 1, (q - 0.7) / 0.3)
476 hsy = q < 0.35 ? lerp(1, 0.7, q / 0.35) : q < 0.7 ? lerp(0.7, 1.08, (q - 0.35) / 0.35) : lerp(1.08, 1, (q - 0.7) / 0.3)
477 }
478 // 掉下去:从落点直直掉到地面(越掉越快),然后往前翻倒
479 const isFall = j.result === 'fall'
480 let lying = 0
481 if (after1 > 0 && isFall) {
482 const q = clamp01(after1 / 0.3)
483 const [, groundY] = P(j.to.gx, j.to.gy, 0)
484 hy = lerp(ey, groundY, q * q)
485 lying = easeOut(clamp01((after1 - 0.3) / 0.2))
486 }
487
488 // 新方块从上面落下来
489 const spawned = isLand ? after.blocks[after.blocks.length - 1] : null
490 const dq = clamp01((t - DROP_AT) / DROP)
491 const drop =
492 spawned && dq > 0
493 ? `<g opacity="${dq.toFixed(2)}" transform="translate(0 ${f1(-50 * (1 - easeOut(dq)))})">${block(spawned)}</g>`
494 : ''
495
496 const draw = (b: Block) => (b === from ? squashed(b, springY) : block(b))
497 // 空中时主角画在最上面;落地 / 掉落后按地面深度插进方块之间,近处的方块会挡住它
498 const depth =
499 isFall && after1 > 0 ? j.to.gx + j.to.gy : after1 > 0 ? (isLand ? target : from).gx + (isLand ? target : from).gy : Infinity
500 const me =
501 isFall && lying > 0
502 ? fallen(j.to.gx, j.to.gy, j.dir, lying)
503 : hero(hx, hy, hsx, hsy, rot, 1, -12, t < FLY ? { blink: 0, armL: 5, armR: 5, look: 0 } : idlePose(clockMs, sideFor(after) === 'left' ? 1.2 : -1.2).pose)
504 const actors = depth === Infinity ? `${byDepth(before.blocks).map(draw).join('')}${me}` : layered(before.blocks, depth, me, draw)
505
506 // 完美波纹画在方块表面(世界坐标);加分飘字画在最上层(屏幕坐标),不会被卡片挡住
507 let fx = ''
508 let pop = ''
509 if (isLand && after1 > 0 && after1 < POP) {
510 const p = after1 / POP
511 const a = p < 0.1 ? p / 0.1 : p > 0.7 ? 1 - (p - 0.7) / 0.3 : 1
512 const rise = 26 * p
513 const px = camera[0] + ex * ZOOM
514 const py = camera[1] + (ey - rise) * ZOOM
515 const main = `+${j.gained}`
516 const mainSize = j.isPerfect ? 26 : 21
517 const mainW = main.length * mainSize * 0.62 + 16
518 const labelW = j.label ? [...j.label].reduce((a, ch) => a + (ch.charCodeAt(0) > 0x2e80 ? 14 : 8), 0) + 16 : 0
519 pop = `<g font-family="${FONT}" font-weight="800" text-anchor="middle" opacity="${a.toFixed(2)}">
520 <rect x="${f1(px - mainW / 2)}" y="${f1(py - 44 - mainSize)}" width="${f1(mainW)}" height="${mainSize + 8}" rx="${(mainSize + 8) / 2}" fill="#FFFDF8" opacity="0.92"/>
521 <text x="${f1(px)}" y="${f1(py - 44)}" font-size="${mainSize}" fill="${j.isPerfect ? '#E76F51' : '#6B5444'}">${main}</text>
522 ${j.label ? `<rect x="${f1(px - labelW / 2)}" y="${f1(py - 70 - 15)}" width="${f1(labelW)}" height="21" rx="10.5" fill="#FFFDF8" opacity="0.92"/><text x="${f1(px)}" y="${f1(py - 70)}" font-size="14" fill="#6B5444">${esc(j.label)}</text>` : ''}
523 </g>`
524 if (j.isPerfect && after1 < 0.6) {
525 const r = after1 / 0.6
526 fx += `<ellipse cx="${f1(ex)}" cy="${f1(ey)}" rx="${f1(lerp(4, target.half * 2, r))}" ry="${f1(lerp(2, target.half * 1.15, r))}" fill="none" stroke="#fff" stroke-width="3" opacity="${(0.9 * (1 - r)).toFixed(2)}"/>`
527 }
528 }
529
530 const world = `${drop}${actors}${fx}`
531 const overAlpha = after.isOver ? clamp01((t - (FLY + 0.5)) / 0.3) : 0
532 const oldCard = factCard(from.fact, topOnScreen(from, camera), sideFor(before), 1 - clamp01(t / 0.15))
533 const newCard = isLand ? factCard(target.fact, topOnScreen(target, camera), sideFor(after), clamp01((t - CARD_AT) / CARD_FADE)) : ''
534 return frame(camera, world, `${hud(isLand && after1 > 0 ? after : before)}${oldCard}${newCard}${pop}${overScreen(after, overAlpha)}`, `jump ${j.n}`)
535}
536types/index.d.ts 104 lines1export type BlockKind = 'cube' | 'disk' | 'terminal' | 'coffee' | 'test' | 'git'
2
3export type Block = {
4 gx: number
5 gy: number
6 half: number
7 height: number
8 kind: BlockKind
9 color: string
10 // 这一块是沿哪条轴从上一块延伸出来的
11 dir: 'x' | 'y'
12 // 这块带的冷知识(id);起点那块没有
13 fact: string | null
14}
15
16export type Game = {
17 blocks: Block[]
18 cur: number
19 score: number
20 combo: number
21 best: number
22 seed: number
23 isOver: boolean
24 jumps: number
25 // 小人实际站的位置(不一定在方块正中);null 表示在当前方块正中
26 at: { gx: number; gy: number } | null
27 // 怎么输的:掉下去了,还是原地踏步
28 lostBy: 'fall' | 'stay' | null
29 // 接下来的方块依次用哪些冷知识(新开一局时从没看过的里随机排好)
30 factQueue: string[]
31 // 完美落地次数(上报排行榜用)
32 perfects: number
33}
34
35export type Jump = {
36 n: number
37 from: { gx: number; gy: number; z: number }
38 to: { gx: number; gy: number }
39 dir: 'x' | 'y'
40 holdMs: number
41 result: 'land' | 'stay' | 'fall'
42 isPerfect: boolean
43 gained: number
44 label: string
45 landZ: number
46}
47
48export type Phase = 'idle' | 'charge' | 'jump'
49
50// 面板画面需要的一切放在一个值里:一次写入只重画一次
51export type View = {
52 game: Game | null
53 before: Game | null
54 phase: Phase
55 jump: Jump | null
56 // 面板下方那行提示
57 hint: string
58 // 输入框的代号:每跳一次换一个新的空框
59 pad: number
60 // 开始蓄力、起跳的时刻:每一帧按「现在离它多久」算画面
61 chargeAt: number
62 jumpAt: number
63 // 这一局第一次按下的时刻(上报时长用)
64 startedAt: number
65}
66
67export type BoardTab = 'today' | 'week' | 'all'
68export type BoardEntry = { rank: number; nickname: string; score: number; isMe: boolean }
69
70// Claude 这边在干什么:决定提示条和面板顶上那行
71export type ClaudeStatus = {
72 busy: boolean
73 needsYou: boolean
74 done: boolean
75 // 输入框上方的「蹦一蹦」提示是否该出现
76 hint: boolean
77}
78
79// 排行榜:自愿加入;没加入时什么都不上报
80export type Board = {
81 joined: boolean
82 nickname: string
83 tab: BoardTab
84 entries: BoardEntry[]
85 me: { rank: number; score: number } | null
86 // 连不上、昵称不合规这类提示
87 status: string
88 busy: boolean
89 // 加载超过 3 秒还没回来:提示网络慢,免得以为坏了
90 slow: boolean
91}
92
93declare module 'claude-code' {
94 interface PluginState {
95 hop: {
96 view: View
97 // 动画帧计数:按住和起跳期间每帧加一,面板读它来逐帧重画
98 tick: number
99 board: Board
100 claude: ClaudeStatus
101 }
102 }
103}
104