护眼卫士接入了 Codex 宠物 —— 聊聊背后的 Rust 技术实现
时隔近半年,护眼卫士迎来了重磅更新。这次我们把当下最火的 Codex 宠物带进了休息屏幕。
做这个功能的出发点很朴素:倒计时 5 分钟干盯着屏幕太无聊了,要是有个小东西在屏幕上跑来跑去,休息时间就没那么难熬。Codex 的宠物生态已经非常成熟,PetDex 上有2000+只精灵,直接复用这套生态比自己画强太多了。
先装为敬:
# 全新安装
brew install --cask rustx-labs/tap/eye-sentry
# 已安装用户手动升级
brew upgrade --cask rustx-labs/tap/eye-sentry整体思路:三层架构
整个宠物系统分三层:
| 层 | 职责 | 技术选型 |
|---|---|---|
| 数据层 | 从 PetDex 拉取宠物清单、下载精灵图、持久化到本地磁盘 | Rust (reqwest + serde + fs) |
| 桥接层 | Tauri Command 把 Rust 的能力暴露给前端 | #[tauri::command] |
| 表现层 | 精灵图渲染、状态机动画、位置移动 | React + CSS |
这个分层不是过度设计,是 Tauri 的架构决定的——所有 IO 操作必须在 Rust 侧完成,前端只能通过 invoke 调用。所以"下载精灵图"这件事天然属于 Rust,"播放帧动画"这件事天然属于前端。
精灵图格式:8×9 的大图
PetDex 的宠物用的是经典的 sprite sheet 格式。一张大图里按 8 列 × 9 行排列,每一格是 192×208 像素的一帧:
行0: idle (6帧) ← 待机呼吸
行1: run_right (8帧) ← 向右跑
行2: run_left (8帧) ← 向左跑
行3: wave (4帧) ← 挥手
行4: jump (5帧) ← 跳跃
行5: fail (8帧) ← 摔倒
行6: wait (6帧) ← 等待
行7: sprint (6帧) ← 冲刺
行8: inspect (6帧) ← 东张西望
渲染方式简单粗暴——用 CSS background-position 偏移显示对应帧,配合 setInterval 切换帧号:
// PetSprite.jsx — 核心渲染逻辑
const ANIMATIONS = {
idle: { row: 0, frames: 6 },
run_right: { row: 1, frames: 8 },
run_left: { row: 2, frames: 8 },
// ... 9 种动画
};
export default function PetSprite({ animation, fps, petId }) {
const [frame, setFrame] = useState(0);
useEffect(() => {
const anim = ANIMATIONS[animation];
const interval = setInterval(() => {
setFrame((f) => (f + 1) % anim.frames);
}, 1000 / fps);
return () => clearInterval(interval);
}, [animation, fps]);
const bgX = -(frame * FRAME_W);
const bgY = -(anim.row * FRAME_H);
return (
<div style={{
width: FRAME_W,
height: FRAME_H,
backgroundImage: `url(${spriteUrl})`,
backgroundPosition: `${bgX}px ${bgY}px`,
backgroundSize: `${FRAME_W * 8}px ${FRAME_H * 9}px`,
}} />
);
}这里有个细节值得说:
精灵图的 URL 不能直接用本地文件路径。
Tauri 的安全模型不允许前端直接访问磁盘文件,所以自定义宠物的精灵图需要在 Rust 侧读出来,转成 base64 Data URL 传给前端:
// petdex.rs — 把 sprite 文件转成 base64 Data URL
#[tauri::command]
pub async fn get_custom_pet_sprite(pet_slug: String) -> Result<String, String> {
let path = custom_pets_dir()?.join(&pet_slug).join("sprite.webp");
let data = std::fs::read(&path).map_err(|e| e.to_string())?;
Ok(format!("data:image/webp;base64,{}", to_base64(&data)))
}这里我手写了一个 to_base64,没引 base64 crate。原因是应用本身就很小,不想为一个 20 行的编码函数加依赖。说实话,如果项目里已经有了 base64 crate 的依赖链,直接用就是了,不用纠结。
状态机:让宠物"活"起来
光会播动画还不够,宠物得能在屏幕上走来走去。这是整个系统里最有趣的部分。
我设计了一个
加权随机状态机:每个姿势有一组可选的后续姿势,每个后续姿势带一个权重。状态切换时按权重随机选下一个姿势,再加上持续时间的随机范围:
// 9 种姿势,每种有 fps 和持续时间范围
const POSES = {
idle: { anim: "idle", fps: 6, duration: [2, 5] },
run_right: { anim: "run_right", fps: 8, duration: [2, 5] },
sprint: { anim: "sprint", fps: 10, duration: [1, 3] },
// ...
};
// 转移表:idle 之后可能做什么?跑步权重3、挥手权重1、等待权重2...
const TRANSITIONS = {
idle: [
{ to: "run_right", weight: 3 },
{ to: "run_left", weight: 3 },
{ to: "wave", weight: 1 },
{ to: "jump", weight: 1 },
{ to: "inspect", weight: 2 },
{ to: "wait", weight: 2 },
],
// ...
};但这里有个关键优化:位置感知。如果宠物已经在屏幕最右边了,继续 run_right 的权重应该降低,否则它会一直在边缘来回弹。我的做法是根据当前位置动态调整权重:
function pickNextPose(current, curX, vw, facingRight) {
const options = TRANSITIONS[current];
const relX = (curX - minX) / range; // 0=左边缘, 1=右边缘
const adjusted = options.map((opt) => {
let w = opt.weight;
if (opt.to === "run_right") w *= 1 - relX; // 越靠右,越不想往右跑
else if (opt.to === "run_left") w *= relX; // 越靠左,越不想往左跑
return { to: opt.to, weight: Math.max(0, w) };
});
// 加权随机选择
// ...
}这个设计让宠物的运动轨迹看起来自然多了——它会在屏幕中间区域活动更频繁,偶尔跑到边缘就转回来。
移动的平滑插值用了 requestAnimationFrame:
// PetAvatar.jsx — RAF 循环做位置插值
useEffect(() => {
const tick = () => {
const m = moveRef.current;
if (m.from && m.to) {
const t = Math.min(1, (performance.now() - m.start) / m.dur);
setPos({
x: m.from.x + (m.to.x - m.from.x) * t,
y: m.from.y + (m.to.y - m.from.y) * t,
});
if (t >= 1) moveRef.current = { from: null, to: null };
}
rafRef.current = requestAnimationFrame(tick);
};
rafRef.current = requestAnimationFrame(tick);
return () => cancelAnimationFrame(rafRef.current);
}, []);没有用 React Spring 或 Framer Motion 这些动画库——因为需求很简单,就是线性插值。一个 RAF 循环 + 一个 ref 就搞定了,没必要引入额外依赖。
Rust 侧:和 PetDex API 打交道
PetDex 提供了一个 manifest API,返回所有可用宠物的列表。我的做法是
首次请求后缓存到本地文件,后续搜索走本地缓存:
// petdex.rs — 加载 manifest,优先读缓存
async fn load_manifest() -> Result<Vec<PetdexEntry>, String> {
let cache_path = manifest_cache_path()?;
// 优先读本地缓存
if cache_path.exists()
&& let Ok(json) = std::fs::read_to_string(&cache_path)
&& let Ok(entries) = serde_json::from_str::<Vec<PetdexEntry>>(&json)
{
return Ok(entries);
}
// 缓存不存在或失效,从 API 拉取
let client = reqwest::Client::new();
let manifest: serde_json::Value = client
.get(format!("{}/api/manifest", PETDEX_API_BASE))
.send().await.map_err(|e| format!("Failed to reach PetDex: {}", e))?
.json().await.map_err(|e| format!("Failed to parse: {}", e))?;
// 解析 → 缓存到磁盘 → 返回
// ...
}搜索命令直接在内存里做模糊匹配,限制返回 10 条:
#[tauri::command]
pub async fn search_petdex_pets(query: String) -> Result<Vec<PetdexEntry>, String> {
let entries = load_manifest().await?;
let q = query.to_lowercase();
Ok(entries.into_iter()
.filter(|e| e.slug.contains(&q) || e.display_name.to_lowercase().contains(&q))
.take(10)
.collect())
}导入一只宠物就是:从 manifest 找到对应的精灵图 URL → 下载 → 存到本地目录 → 写配置:
#[tauri::command]
pub async fn import_petdex_pet(pet_slug: String) -> Result<CustomPet, String> {
let slug = extract_slug(&pet_slug);
let entries = load_manifest().await?;
// 在 manifest 里找到目标宠物
let pet_entry = entries.iter()
.find(|p| p.slug == slug)
.ok_or_else(|| format!("Pet '{}' not found on PetDex", slug))?;
// 下载精灵图
let sprite_data = client.get(&pet_entry.spritesheet_url)
.send().await?.bytes().await?;
// 存到 custom-pets/{slug}/sprite.webp
let pet_dir = custom_pets_dir()?.join(&slug);
std::fs::create_dir_all(&pet_dir).map_err(|e| e.to_string())?;
std::fs::write(pet_dir.join("sprite.webp"), &sprite_data)?;
// 更新 custom-pets.json
let mut pets = load_custom_pets_map()?;
pets.insert(slug, custom_pet.clone());
save_custom_pets_map(&pets)?;
Ok(custom_pet)
}整个过程不需要数据库,就是 JSON 文件 + 目录结构:
~/Library/Application Support/eye-sentry/
├── config.json ← 包含 "pet": "tiko" 字段
├── custom-pets.json ← { "pikachu": { id, name, desc, sprite_remote_url } }
├── custom-pets/
│ ├── pikachu/
│ │ └── sprite.webp ← 下载的精灵图
│ └── kirby/
│ └── sprite.webp
└── petdex-manifest.json ← API 缓存
这个设计可能不是最优的,但它有个好处:
用户可以手动管理宠物文件。想换一只?直接替换 sprite.webp 就行。调试的时候也能直接看文件内容。
前后端桥接:Tauri Command
Rust 侧定义好 command 后,在 lib.rs 里注册:
// lib.rs
.invoke_handler(tauri::generate_handler![
// ... 其他命令
config::get_pet,
config::set_pet,
petdex::search_petdex_pets,
petdex::import_petdex_pets,
petdex::get_custom_pets,
petdex::get_custom_pet_sprite,
petdex::delete_custom_pet,
])前端通过 invoke 调用,用 listen 监听后端推送的 break_reminder 事件来同步状态。休息窗口启动时的初始化流程:
// BreakApp.jsx
// 1. 获取当前选中的宠物 ID
const storedPet = await invoke("get_pet");
// 2. 加载所有自定义宠物,注册到前端内存
const customPets = await invoke("get_custom_pets");
for (const pet of customPets) {
registerCustomPet(pet); // 注册元数据
const dataUrl = await invoke("get_custom_pet_sprite", { petSlug: pet.id });
setCustomSpriteUrl(pet.id, dataUrl); // 注册 base64 精灵图
}
// 3. 渲染
setPetId(storedPet);注意这里有个顺序问题:必须先把自定义宠物的精灵图注册到内存,再设置 petId。否则 PetSprite 组件去找精灵图 URL 时会发现找不到。这个 bug 我踩过——宠物显示不出来,排查半天发现是注册顺序的时序问题。
内置宠物 vs 自定义宠物
内置宠物是打包在 public/pets/ 目录里的,Vite 会直接把它们 serve 出来:
public/pets/
├── tiko/sprite.webp ← Tiko,默认小机器人
├── kabi/sprite.webp ← 卡比兽
├── goose-default/sprite.png ← 小鹅
├── capvolt/sprite.webp ← 皮卡丘
├── maja/sprite.webp ← 萨摩耶
└── nai-long/sprite.webp ← 奶龙
自定义宠物是从 PetDex 下载的,运行时通过 registerCustomPet 动态注入到前端的宠物注册表:
// pets.js — 运行时合并
const customPetsMap = {};
export function registerCustomPet(pet) {
customPetsMap[pet.id] = {
...pet,
spriteUrl: pet.sprite_remote_url || pet.spriteUrl,
isCustom: true,
};
}
export function getAllPets() {
return { ...PETS, ...customPetsMap }; // 内置 + 自定义
}这两种宠物的区别只在精灵图来源不同——内置的直接用 URL,自定义的用 base64 Data URL。渲染逻辑完全一样,PetSprite 不关心数据从哪来。
踩过的几个坑
1. 精灵图格式不是统一的
PetDex 上的宠物有 webp 和 png 两种格式。内置的小鹅是 sprite.png,其他都是 sprite.webp。下载自定义宠物时我硬编码了 sprite.webp,后来发现有的宠物实际上传的是 png。不过好在前端渲染不关心后缀——它只看 backgroundImage 加载是否成功,base64 里自带 MIME type。
2. 多显示器下每个屏幕独立渲染
休息窗口是每个显示器一个(break-0、break-1),但宠物状态是独立的——每只宠物在自己的屏幕上有自己的位置和运动轨迹。这倒不是说设计多高明,而是因为每个窗口有独立的 React 根组件,状态天然隔离。
3. base64 编码大图的开销
一张 1536×1872 的 sprite sheet,webp 压缩后大约 700KB,转 base64 后膨胀到约 930KB。如果用户导入了很多宠物,每个都要转 base64 传给前端,初始化会变慢。目前的方案够用,但如果宠物数量增长到几十只,可能需要改成 Tauri 的 asset protocol 或者用 SQLite 存索引。
结论
这个功能从想法到实现花了大约两天。核心工作量在三个地方:
- 研究 PetDex 的数据格式和 API —— 没有文档,全靠抓包和读 Codex 源码
- 状态机的设计 —— 让运动轨迹看起来自然,而不是随机乱跑
- 前后端数据流 —— Tauri 的安全限制让"读一个本地文件给前端用"这件小事变得绕了一圈
做完了回头看觉得每个部分都不复杂。但做的时候确实在精灵图渲染、状态机转移权重、base64 桥接这些地方反复调试了很久。希望这篇拆解能帮到想在桌面应用里加宠物系统的朋友——核心就是 sprite sheet + 状态机 + 一层 Tauri bridge,没那么神秘。
最后欢迎 MacOS 用户使用护眼卫士 · 让眼睛休息变成一件有趣的事 🐾
# 全新安装
brew install --cask rustx-labs/tap/eye-sentry
# 已安装用户手动升级
brew upgrade --cask rustx-labs/tap/eye-sentry