前言
最近泄漏的 Claude Code(一款 AI 编程工具)源码中有 Buddy 系统,一个虚拟宠物伙伴(Buddy)系统(愚人节彩蛋)。每个用户都会根据自己的 ID 确定性地生成一只独一无二的 ASCII 小宠物,它会在终端里陪你写代码,你还可以摸它的头。
原始实现和 React/Ink、Bun 运行时深度耦合,没法单独拿出来用。于是我把核心逻辑提取出来,重新实现为一个零运行时依赖 、三端通用 (终端 / 浏览器 / Node.js)的独立包------buddy-sprite。
lua
__
<(· )___
( ._>
`--´
Quackers ★★★
它能做什么?
一句话概括:给你的终端或网页加一只 ASCII 宠物。
核心特性:
- 🎲 确定性生成 :相同的
userId永远生成相同的宠物,就像区块链地址生成头像一样 - 🐣 18 种物种:鸭子、猫、龙、幽灵、六角恐龙、机器人、水豚、仙人掌......
- 🎨 丰富的外观:6 种眼睛样式 × 8 种帽子(皇冠、巫师帽、螺旋桨帽......)
- ⭐ 稀有度系统:普通(60%) → 罕见(25%) → 稀有(10%) → 史诗(4%) → 传说(1%)
- 📊 5 项属性:调试力、耐心、混乱、智慧、毒舌
- ✨ 闪光变体:1% 概率获得闪光宠物
- 💬 对话气泡:带自动淡出动画
- 🤚 摸头互动:浮动爱心动画
- 😴 待机动画:眨眼、小动作、呼吸
- 📦 零依赖:纯 TypeScript,打包后约 14KB
技术实现
1. 确定性生成:从字符串到宠物
整个生成系统的核心思想是:一个字符串输入,确定性地输出一只完整的宠物。
userId + salt → FNV-1a 哈希 → Mulberry32 PRNG 种子 → 宠物属性
FNV-1a 哈希
首先用 FNV-1a 算法把字符串转成一个 32 位整数:
ts
function hashString(input: string): number {
let hash = 2166136261
for (let i = 0; i < input.length; i++) {
hash ^= input.charCodeAt(i)
hash = Math.imul(hash, 16777619)
}
return hash >>> 0
}
FNV-1a 的优点是实现简单、分布均匀、碰撞率低,非常适合这种场景。
Mulberry32 伪随机数生成器
有了种子之后,用 Mulberry32 算法生成伪随机数序列:
ts
function mulberry32(seed: number): () => number {
let state = seed >>> 0
return function nextRandom() {
state |= 0
state = (state + 0x6d2b79f5) | 0
let temp = Math.imul(state ^ (state >>> 15), 1 | state)
temp = (temp + Math.imul(temp ^ (temp >>> 7), 61 | temp)) ^ temp
return ((temp ^ (temp >>> 14)) >>> 0) / 4294967296
}
}
Mulberry32 是一个周期为 2³² 的 PRNG,质量远超简单的 LCG,但实现只有几行代码。关键是它是确定性的------相同的种子永远产生相同的随机数序列。
加权稀有度抽取
稀有度的抽取使用加权随机:
ts
const RARITY_WEIGHTS = { common: 60, uncommon: 25, rare: 10, epic: 4, legendary: 1 }
function rollRarity(rng: () => number): Rarity {
const totalWeight = Object.values(RARITY_WEIGHTS).reduce((sum, w) => sum + w, 0)
let roll = rng() * totalWeight
for (const rarity of RARITIES) {
roll -= RARITY_WEIGHTS[rarity]
if (roll < 0) return rarity
}
return 'common'
}
传说级宠物只有 1% 的概率,但如果你的 userId 命中了,那它就是永远的传说。
2. ASCII 精灵帧动画
每种物种都有 3 帧 ASCII 动画,通过模板中的 {E} 占位符来替换不同的眼睛样式:
ts
const BODIES = {
cat: [
[' ', ' /\\_/\\ ', ' ( {E} {E}) ', ' ( ω ) ', ' (")_(") '],
[' ', ' /\\_/\\ ', ' ( {E} {E}) ', ' ( ω ) ', ' (")_(")~ '],
[' ', ' /\\-/\\ ', ' ( {E} {E}) ', ' ( ω ) ', ' (")_(") '],
],
// ... 18 种物种
}
帽子作为独立的装饰行叠加在精灵顶部:
ts
const HAT_LINES = {
crown: ' \\^^^/ ',
wizard: ' /^\\ ',
halo: ' ( ) ',
// ...
}
渲染时只需要简单的字符串替换和行拼接,没有任何 Canvas 或 DOM 操作。
3. 动画状态机:BuddyEngine
BuddyEngine 是整个系统的核心,它是一个基于 tick 的动画状态机:
scss
┌─────────────┐
│ Idle State │ ← 默认状态,播放待机序列
│ [0,0,0,0, ││ 1,0,0,0, │ ← -1 = 眨眼, 1/2 = 小动作帧│ -1,0,0,2, ││ 0,0,0] │
└──────┬──────┘
│ speak() / pet()
▼
┌─────────────┐
│ Active State │ ← 快速循环所有帧
│ tick % 3 │
└──────┬──────┘
│ 超时
▼
┌─────────────┐
│ Fade State │ ← 气泡淡出
└──────┬──────┘
│
▼
回到 Idle
每个 tick(500ms)引擎会计算当前帧数据,然后通过回调发出一个 BuddyFrame 对象:
ts
interface BuddyFrame {
spriteLines: string[] // ASCII 精灵行
name: string // 宠物名字
face: string // 单行脸部表情
bubbleLines: string[] | null // 对话气泡文字
isBubbleFading: boolean // 气泡是否正在淡出
isPetting: boolean // 是否正在显示爱心
colorKey: ColorKey // 稀有度颜色键名
}
这个设计的精妙之处在于:引擎只负责计算状态,不负责渲染。渲染完全交给适配器层。
4. 适配器模式:一套引擎,多端渲染
scss
┌──────────────────┐
│ BuddyEngine │
│ (动画状态机) │
└────────┬─────────┘
│ BuddyFrame
┌──────────────┼──────────────┐
▼ ▼ ▼
┌────────────┐ ┌────────────┐ ┌────────────┐
│ Terminal │ │ Browser │ │ Custom │
│ Adapter │ │ Adapter │ │ Adapter │
│ (ANSI码) │ │ (DOM/CSS) │ │ (自定义) │
└────────────┘ └────────────┘ └────────────┘
- 终端适配器:使用 ANSI 转义码实现颜色和光标控制,原地刷新避免闪烁
- 浏览器适配器:创建 DOM 元素,用 CSS transition 实现淡入淡出
- 自定义适配器 :直接消费
BuddyFrame,想怎么渲染都行
快速上手
安装
bash
npm install buddy-sprite
终端使用
ts
import { assembleCompanion } from 'buddy-sprite'
import { createTerminalBuddy } from 'buddy-sprite/terminal'
const companion = assembleCompanion('your-user-id', {
name: 'Quackers',
personality: '一个热爱调试的快乐小伙伴',
hatchedAt: Date.now(),
})
const buddy = createTerminalBuddy(companion)
buddy.start()
buddy.speak('嘎嘎!你好呀!')
buddy.pet() // 摸头,会出现爱心动画
// 不用时记得销毁
// buddy.destroy()
浏览器使用
html
<div id="buddy-container"></div>
<script type="module">
import { assembleCompanion } from 'buddy-sprite'
import { createBrowserBuddy } from 'buddy-sprite/browser'
const companion = assembleCompanion('your-user-id', {
name: 'Quackers',
personality: '快乐',
hatchedAt: Date.now(),
})
const buddy = createBrowserBuddy(
document.getElementById('buddy-container'),
companion,
)
buddy.start()
buddy.speak('来自浏览器的问候!')
</script>
自定义渲染
如果你想在其他环境(比如 Electron、游戏引擎、甚至硬件终端)中使用,可以直接消费引擎输出:
ts
import { BuddyEngine, assembleCompanion } from 'buddy-sprite'
const companion = assembleCompanion('your-user-id', {
name: 'Quackers',
personality: '快乐',
hatchedAt: Date.now(),
})
const engine = new BuddyEngine(companion, (frame) => {
// frame.spriteLines --- ASCII 精灵行
// frame.bubbleLines --- 对话气泡文字
// frame.isPetting --- 是否正在显示爱心
// 你可以用任何方式渲染这些数据
console.clear()
console.log(frame.spriteLines.join('\n'))
})
engine.start()
架构总览
r
buddy-sprite/
├── src/
│ ├── index.ts # 统一导出
│ ├── types.ts # 类型定义(稀有度、物种、眼睛、帽子、属性)
│ ├── sprites.ts # 18 种物种的 ASCII 精灵帧数据 + 渲染函数
│ ├── companion.ts # 宠物生成(PRNG、哈希、稀有度抽取、属性生成)
│ ├── engine.ts # BuddyEngine --- 动画状态机(核心)
│ └── adapters/
│ ├── terminal.ts # 终端适配器(ANSI 转义码)
│ └── browser.ts # 浏览器适配器(DOM 渲染)
└── examples/
├── terminal-demo.ts # 终端演示脚本
└── browser-demo.html # 浏览器演示页面(直接打开,无需构建)
整个项目的设计遵循几个原则:
- 零依赖:不依赖任何第三方包,纯 TypeScript 实现
- 确定性:相同输入永远产生相同输出,没有任何随机性
- 关注点分离:引擎只管状态计算,适配器只管渲染
- 三端通用:ESM + CJS + DTS,终端/浏览器/Node.js 都能用
一些有趣的设计细节
为什么用 FNV-1a + Mulberry32?
在浏览器和 Node.js 中,Math.random() 不支持种子。我们需要一个确定性的随机数生成器,而且要足够轻量。FNV-1a 哈希 + Mulberry32 PRNG 的组合总共不到 20 行代码,但质量足够好,分布均匀,周期为 2³²。
为什么属性有"峰值"和"低谷"?
每只宠物会随机选一个"峰值属性"和一个"低谷属性",其余属性随机分布。这让每只宠物都有自己的"性格"------比如一只高混乱低耐心的猫,或者一只高智慧低毒舌的猫头鹰。比起所有属性都差不多的平庸分布,这种设计更有趣。
为什么帽子只给非普通稀有度?
普通宠物(60% 概率)没有帽子,这让帽子成为一种"稀有标志"。当你看到一只戴着皇冠的传说级龙,那种感觉是不一样的。
总结
buddy-sprite 是一个有趣的小项目,它展示了几个值得关注的技术点:
- 确定性随机生成:FNV-1a + Mulberry32 的组合在前端场景中非常实用
- ASCII 帧动画:用最简单的字符串操作实现流畅的动画效果
- 引擎-适配器分离:一套核心逻辑,多端渲染,这种架构模式值得借鉴
- 零依赖设计:在依赖爆炸的前端生态中,保持克制是一种美德
如果你觉得有趣,欢迎 Star ⭐ 和 PR:
- GitHub :github.com/novlan1/bud...
- 在线体验 :novlan1.github.io/buddy-sprit...
- npm :
npm install buddy-sprite
本文项目灵感来源于 Claude Code 泄漏源码中隐藏的 Buddy 系统。原始实现使用 React + Ink,buddy-sprite 是一个全新的独立实现。
