返回文章列表

护眼卫士接入了 Codex 宠物 —— 聊聊背后的 Rust 技术实现

538·4 分钟阅读
RustTauri桌面应用Codex Pet护眼卫士

时隔近半年,护眼卫士迎来了重磅更新。这次我们把当下最火的 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-0break-1),但宠物状态是独立的——每只宠物在自己的屏幕上有自己的位置和运动轨迹。这倒不是说设计多高明,而是因为每个窗口有独立的 React 根组件,状态天然隔离。

3. base64 编码大图的开销

一张 1536×1872 的 sprite sheet,webp 压缩后大约 700KB,转 base64 后膨胀到约 930KB。如果用户导入了很多宠物,每个都要转 base64 传给前端,初始化会变慢。目前的方案够用,但如果宠物数量增长到几十只,可能需要改成 Tauri 的 asset protocol 或者用 SQLite 存索引。

结论

这个功能从想法到实现花了大约两天。核心工作量在三个地方:

  1. 研究 PetDex 的数据格式和 API —— 没有文档,全靠抓包和读 Codex 源码
  2. 状态机的设计 —— 让运动轨迹看起来自然,而不是随机乱跑
  3. 前后端数据流 —— Tauri 的安全限制让"读一个本地文件给前端用"这件小事变得绕了一圈

做完了回头看觉得每个部分都不复杂。但做的时候确实在精灵图渲染、状态机转移权重、base64 桥接这些地方反复调试了很久。希望这篇拆解能帮到想在桌面应用里加宠物系统的朋友——核心就是 sprite sheet + 状态机 + 一层 Tauri bridge,没那么神秘。


最后欢迎 MacOS 用户使用护眼卫士 · 让眼睛休息变成一件有趣的事 🐾

# 全新安装
brew install --cask rustx-labs/tap/eye-sentry
 
# 已安装用户手动升级
brew upgrade --cask rustx-labs/tap/eye-sentry