时隔近半年,护眼卫士迎来了一次重要的功能更新——我们把当下热门的 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。原因是应用本身就很小,不想为了一个二十行的编码函数多加依赖。当然,如果项目里本来就有 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 时可能会一头雾水找不到——之前就因为这个顺序问题,宠物死活显示不出来,排查了半天才定位到。
内置宠物 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