desktop-pet-character · git:20261103.1293e08 · 2026-11-03 · sha256 f62a3ce7c1b21c9b

desktop-pet-character git:20261103.1293e08A

Immutable. This exact content never changes and is served at /api/v1/blob/f62a3ce7c1b21c9b.

---
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` 顶部常量块)+ 调表情参数表,绘制骨架不用动。