---
name: desktop-pet-character
description: "做桌面宠物 / 桌面虚拟人物：立绘生成 → 拆层程序化动画（说话嘴型、眨眼、打字手、表情）→ PySide6 悬浮窗桌面端 → canvas 2D Web 端移植。当用户要「桌面宠物 / 桌宠 / 虚拟形象 / AI 人物立绘动画 / 语音助手形象 / 会说话会打字的角色」，或要把一个角色形象同时跑在桌面和网页里时使用。含完整可运行案例（PySide6 桌宠 + Web canvas 渲染器）、坐标标定方法、程序化动画公式与九个实测视觉陷阱。"
---

# 桌面宠物 / 桌面虚拟人物

做一类**有人格的角色形象**：它站在桌面右下角或网页舞台里，会呼吸、眨眼、微笑，说话时嘴巴真的在动，写代码时双手真的在键盘上敲。和"画一张角色图"的差别不在画功，在**动**——静态立绘是素材，程序化动画才是角色。

完整案例见 `reference/`（PySide6 桌面端，`python main.py` 直接跑）和 `web/`（canvas 2D 渲染器，React 可直接接入）。立绘资产在 `assets/`。

## 一、总流程

```
立绘(ImageGen) → 坐标标定(量眼睛/嘴/脖子) → 桌面端(Qt拆层动画) → 放大截图验收 → 状态机打磨 → Web canvas 移植
```

每一步都有独立产物，**放大截图验收不可省**——这次所有严重缺陷（嘴僵、手露洞、键盘光过曝、动画卡死）都是放大截图看出来的，没有一条是控制台报错。

## 二、立绘准备

1. 用 ImageGen 生成**姿势居中、无背景杂物**的全身立绘（PNG，透明底或可抠图）。同一个角色生成两套姿势：站姿 + 坐姿（打键盘用），切换时交叉淡入。
2. 需要 AI 抠图时用洋红底：生成时要求纯洋红背景，再用 `tools/key_magenta.py` 抠成透明。
3. **先量坐标再写代码**。在原图上量出：双眼中心、眼半径、嘴中心、腮红位置、脖子轴 y。量错一两个像素，后面所有动画都歪。这次眼睛坐标量错一次，出现"斜切边"，重量后才修好。
4. 多尺寸场景（桌面 840px 原图 / Web 压缩 webp）用同一坐标系：**所有源坐标按原图量，运行时乘 K = 实际图宽/原图宽**，换图尺寸不用改代码。

## 三、桌面端骨架（PySide6）

- `QMainWindow` + `Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint | Qt.Tool` + `WA_TranslucentBackground`，全窗透明，只画角色。
- 一个 `QTimer(~33ms)` 驱动主循环，`self.t += dt` 累积时间，全部动画是 `f(t)` 纯函数。
- 状态机：`idle / listening / thinking / answering / coding`，每态一套表情参数表。
- 对外控制走 `127.0.0.1:8756` 本地 TCP，每行一个 JSON（`{"cmd":"say","text":...}`），宿主程序（语音面板等）随时驱动它。
- **启动必须 `run_in_background`**：GUI 进程会阻塞终端，pythonw 退出码 127 是误报，别被骗。

## 四、动画命脉：时间一定要传到每一层

**⚠️ 最大的一次翻车**：主窗口 `_tick()` 更新了 `self.t`，但状态对象里的 `st.t` 从未写入，所有角色层拿到的 t 恒为 0——动画全部静止，"像一张贴图"，且无任何报错。

规矩：**每一帧先把时间同步到状态对象，再画**。任何"我传了 t 但它不动"的问题，第一件事打印那层的 t 是不是 0。

## 五、拆层立绘：动起来的根本

静态立绘只有一个图层，想动只能**切开**：

- **头/身分层**：沿脖子 y 切两刀（`NECK_Y`），两刀之间留 `OVERLAP`（约 110px）遮住接缝。头层绕脖子轴旋转（点头、歪头、说话轻点），身体层只做呼吸 bob。头的转角 = 基础角(按状态) + 摆动(慢正弦) + 点头(说话时跟音节)。
- **眨眼**：取眼睛区域矩形，先铺一层肤色渐变盖住原眼，再把原眼区域 `drawImage` 纵向压缩贴回。压缩高度最小别到 0（留 6%），否则闭合瞬间闪黑边。**铺肤色必须先用立绘自身肤色采样**，且覆盖椭圆收到 `hw*1.08, hh*1.10`——大了会在脸上留亮白圆斑（这次踩过：站姿鼻下、坐姿脸上各留过一个）。
- **闭眼线**：压缩到一半以下补一条向下的弧线，否则"眯眼"不像闭眼。

## 六、说话嘴型（最容易做僵的部分）

用户原话："嘴部幅度太小，面部表情很僵硬，不太微笑"。要点：

**1. 音节包络**——说话不是匀速开合，三层不同频率叠加 + 周期性换气闭口：

```python
a = abs(sin(t*12.6)); b = abs(sin(t*5.1+1.2)); c = abs(sin(t*21.3+0.4))
env = 0.5*a + 0.32*b + 0.18*c          # 主 + 次声部
v = env * (0.68 + 0.54*(0.5+0.5*sin(t*1.9)))   # 慢包络：有轻有重
gap = max(0, sin(t*1.15+2.2))**8        # 换气口：偶尔短暂闭嘴
open = clamp(v * (1 - 0.85*gap))
```

**2. 嘴的几何必须满足"笑口不等式"**：`corner = vy - lift`（嘴角），`top_y = corner + oh*0.34`（上唇中线）。嘴角永远高于上唇中线，几何上永远成立微笑；`lift = hh*(0.3+0.78*smile)`，"笑"全部体现在嘴角上扬量里。开大时视觉重心上移 `vy = cy - oh*0.22`，别把下巴吃光。

**3. 开口幅度要够大**：`oh = hh*(0.48 + 3.10*opn)`——初期只给了 5px 位移，远看嘴根本没动。张口系数 3 倍起步。

**4. 宽度单独走包络**：`shape = 0.86+0.30*|sin(t*9.1)|`——有的音节是"啊"（竖长），有的是"咿"（横宽），只动高度像机器人打哈欠。

**5. 细节清单**：口腔径向渐变（上深下浅）+ 深处阴影 + 牙齿（`setClipPath(口腔)` 后画矩形，宽收到口腔 78%、透明度压低，否则像假牙）+ 扁舌头 + 下唇柔光 + 嘴角笑纹。**先盖掉立绘自带的抿嘴**（肤色径向渐变，边缘 alpha 渐出），不然画两层嘴。

**6. 表情驱动表**：`_expr(mode,t) → (smile, open, blush)`，每个状态一套参数 + 微小正弦扰动。表情僵硬的根源通常不是画法，是参数表太"平"——笑意要带呼吸感（`0.72+0.14*sin(t*3.1)` 这种），腮红跟着情绪走。

## 七、打字手（coding 状态）

"手在巴拉巴拉地敲，要很灵动"的实测做法：

- 双手各是一个**羽化椭圆 patch**（肤色 + 阴影），不是贴图。
- 敲击 = 以**腕为轴**旋转下压：指端纵向收 7% + 上移，形成"按压回弹"。整只手平移会露腕洞或出现双影。
- **连击拖影**：每只手画两遍——先画 0.06~0.58 透明度的历史位置（拖影），再画 0.94 的实手。顺序反了拖影会盖住手。
- 键位联动：手的位置映射到键盘行列（`base_c` 由手 x 算），按键亮点画在手指下方对应键位上，敲击瞬间该键最亮。

## 八、键盘发光（浅色底加色光的坑）

白/浅色键盘上直接加色（`Plus` 混色）会把白键帽推成纯白过曝，光感全无。实测正确的混色顺序：

1. **AO 暗部**：先 Normal 混色把键缝、键底压成深蓝灰
2. **冷色 tint**：整体罩一层薄冷色
3. **Plus 键隙光**：只在键缝处加色，键帽本体不加
4. **前缘灯条**：饱和青 `#5FDAFF` 直接混色画一条横带
5. bloom（ downscaled 提亮再叠回）+ 按键亮点

规律：**深色上用加色发光，浅色上先压暗再加色**。

## 九、Qt → Web canvas 移植

- 全部绘制改写为 canvas 2D：`QPainter` 渐变 → `createLinearGradient/RadialGradient`；`QPainterPath` → `Path2D`；混合模式名对应（`CompositionMode_Plus` → `lighter`）。
- **⚠️ Qt 颜色的 alpha 是 0–255，canvas rgba() 要 0–1**：统一封装 `rgba(hex, a255)` 内部除 255。漏一处就是"这层怎么全不透明/全透明"。
- 设计空间法：所有绘制在固定设计尺寸（如 200×200）里完成，调用方负责 transform；源坐标乘 K 换算。渲染器写成纯函数 `drawPetFrame(ctx, img, mode, t, blink, k)`，不依赖 React，组件只是薄壳。
- Web 端立绘压成 webp（1000px 高足够），桌面端保留原 PNG。

## 十、验收流程

1. **脸部特写**：写一个 8 倍放大只裁脸部的脚本（`tools/face_big.py`），每种状态截一张，用眼睛看。嘴开合、牙齿、腮红、笑眼全在这一步验。
2. **多帧连拍**：同状态连拍 6–10 帧，确认动画真的在动、幅度够大。
3. **GIF**：合成一段 2–3 秒循环 GIF 肉眼过一遍（`tools/make_gif.py`）。
4. **状态图**：五种状态拼一张对照图（`tools/render_states.py`），查表情分化。
5. 手部/键盘单独放大截图。
6. Web 端用 playwright 截图和桌面端逐帧对照。

## 十一、文件地图

| 文件 | 作用 |
|------|------|
| `reference/main.py` | 桌宠入口 |
| `reference/pet/window.py` | 无边框透明窗 + 主循环 + 控制通道（含 `st.t` 同步） |
| `reference/pet/characters.py` | 全部绘制：拆层、嘴、眼、腮红、打字手、键盘光 |
| `reference/pet/control.py` | 8756 端口 JSON 控制协议 |
| `reference/pet/state.py` | 状态机 |
| `reference/tools/face_big.py` | 8 倍脸部特写（验收利器） |
| `reference/tools/make_gif.py` / `render_states.py` | GIF / 状态对照图 |
| `reference/tools/key_magenta.py` | 洋红底抠图 |
| `web/xiaoxi-pet-render.ts` | canvas 2D 渲染器（纯函数，含全部公式） |
| `web/XiaoxiStagePet.tsx` + `xiaoxi-stage-pet.css` | React 接入示例 |
| `assets/girl.png` / `girl_desk.png` | 站姿 / 坐姿立绘（840×1333） |
| `assets/xiaoxi_anim.gif` / `girl_states.png` | 动画示例 / 状态对照 |

新增角色时：换立绘 + 重量坐标（`characters.py` 顶部常量块）+ 调表情参数表，绘制骨架不用动。
