SLOPSHOPPER

hop

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

newpanebandguardcommandtoast
★ 2v0.5.2MITupdated 2026-10-06AGIHunt/cc-mods/plugins/hop
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · hop
│ ┃ 蹦一蹦 ✕ › fix the failing auth test and add an audit log call │ ┃ Hop runs in the Claude desktop app. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /hop │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · 蹦一蹦
Hop runs in the Claude desktop app.
README

Hop

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.

  • Every block carries an AI coding knowledge card (Claude Code, Codex, DeepSeek Harness, Cursor and more). Cards you haven't seen come first
  • Land dead center for a perfect; chained perfects score more and more
  • Difficulty ramps up with each jump and tops out around jump 45
  • Sounds (played with macOS afplay); English and Chinese
  • Optional AGI Hunt leaderboard: only a nickname and scores are sent, and you can leave and delete your data any time

Stays 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).

Settings

Run /plugin and open hop:

SettingDefaultWhat it does
Hint while Claude worksonShow the hint above the prompt during long turns
Hint after (seconds)5How long into a turn before the hint shows
Open the pane by itselfoffOpen the game pane instead of only hinting
Languageautoauto follows the system language; en or zh forces English or Chinese (start a new session to apply)

Code map

FileWhat it does
hooks/register.tsxAll hooks: when to appear, key handling, frame-by-frame redraws, sounds, leaderboard requests, pane layout
hooks/game.tsPure game logic: block generation, jump physics, scoring, difficulty curve (no UI, easy to unit test)
hooks/scene.tsDraws a game state as one static SVG frame: blocks, hero (or whale), animation, cards, results
hooks/facts.tsLoads the knowledge cards and queues unseen ones first
hooks/i18n.tsEnglish and Chinese strings; add a language here
data/facts.jsonKnowledge cards with sources; format in the repo's CONTRIBUTING
sounds/*.wavSounds synthesized by scripts/make_sounds.py in the repo; tweak and regenerate
tests/Tests run by claude plugin test

Common tweaks

  • Feel / difficulty: SPEED (distance per millisecond held) and MAX_HOLD_MS at the top of game.ts, and the difficulty curve in nextBlock.
  • New block kinds: SPECIAL in game.ts (bonus points); KIND_COLOR and decal in scene.ts (looks).
  • Animation timing: FLY, PAN, DROP, POP in scene.ts.
  • When it appears: the 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.

Source 6 files
hooks/register.tsx 685 lines
1import 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}
685
hooks/facts.ts 121 lines
1// 方块上的冷知识卡片。完整题库在 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
121
hooks/game.ts 149 lines
1import 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}
149
hooks/i18n.ts 119 lines
1// 中英双语:默认跟随系统语言(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}
119
hooks/scene.ts 536 lines
1import 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">&gt;_</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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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}
536
types/index.d.ts 104 lines
1export 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