docs(win): 任务说明、DSH 约束与交付文档

This commit is contained in:
2026-09-14 20:04:54 +08:00
parent 315cf79708
commit a83e4f6a1f
15 changed files with 975 additions and 0 deletions
+41
View File
@@ -0,0 +1,41 @@
# T-WIN-001 Godot 项目骨架
- **状态**:done
- **依赖**:无
## 目标
建立 Godot 4 工程骨架,配置 4 个 autoload、渲染方式与窗口,保证可无头导入与启动。
## load
```
DSH.md
project.godot
src/autoload/*.gd
src/ui/main.gd
scenes/main.tscn
```
## 交付物
| 文件 | 说明 |
|---|---|
| `project.godot` | 工程配置;autoload = AppConfig / CatalogStore / PoseReceiver / ScoreService |
| `scenes/main.tscn` | 只含一个挂载 `src/ui/main.gd` 的 Control 根节点(界面用代码构建,见 DSH.md §6) |
| `src/ui/main.gd` | 应用外壳:构建布局、切换页面、启动接收服务 |
| `src/autoload/app_config.gd` | 客户端配置,持久化到 `user://opendance.cfg` |
| `src/autoload/catalog_store.gd` | 目录缓存 + 模型下载缓存 + 关键点序列缓存 |
| `src/autoload/pose_receiver.gd` | 关键点接收服务封装 |
| `src/autoload/score_service.gd` | 评分服务封装 |
## 验收
```bash
GODOT --headless --path . --import # 无 SCRIPT ERROR
GODOT --headless --path . --quit-after 180 # 应用可启动,UI 构建无报错
```
## 结果
- 导入无编译错误;`--quit-after 180` 启动 180 帧无任何运行时错误。
+44
View File
@@ -0,0 +1,44 @@
# T-WIN-002 调用 Web 服务端 API
- **状态**:done
- **依赖**:T-WIN-001
## 目标
调用 opendance-web 的 REST 接口拉取目录与参考动作序列,只消费冻结契约。
## load
```
DSH.md
contracts/rest-api.yaml # 锚点:#/paths/~1api~1catalog
contracts/data-models.md # 锚点:§2 Song、§3 Motion、§4 Model、§6 CatalogBundle
src/net/api_client.gd
src/net/catalog_types.gd
src/autoload/catalog_store.gd
```
## 交付物
| 文件 | 说明 |
|---|---|
| `src/net/api_client.gd` | HTTPRequest 封装;统一返回 `{ok,status,data,error}`,不抛异常 |
| `src/net/catalog_types.gd` | Song / Motion / Model / CatalogBundle / SourceInfo 类型化解析 |
| `src/net/keypoint_sequence.gd` | NDJSON 关键点序列 → 特征向量序列 |
| `src/autoload/catalog_store.gd` | 目录刷新、模型文件下载缓存、序列缓存 |
## 关键实现
- `default_model()` 直接使用服务端解析好的 `defaultModelId`,客户端不重复实现 VRM 优先级
- `Motion.is_scorable()` 要求 `preprocessState == "done"` 且有帧数
- 错误体解析兼容 NestJS 的 `message: string[]`
- 模型文件按 `user://models/<id>.<ext>` 缓存,避免重复下载 10 MB 级 VRM
## 验收
`tests/test_api_integration.gd` 对**真实运行中**的服务端断言:
health / catalog version / catalog export / 关键点序列,服务端不可达时显式跳过。
## 结果
集成测试通过:目录解析、来源五元组齐备性、`defaultModelId` 解析均符合契约。
+45
View File
@@ -0,0 +1,45 @@
# T-WIN-003 接收 Android 关键点帧
- **状态**:done
- **依赖**:T-WIN-001
## 目标
在本端监听 `ws://<host>:8765/pose`,作为 WebSocket **服务端**接收 Android 辅助 App 的推流。
## load
```
DSH.md
contracts/websocket-keypoints.md # 锚点:§1 端点与子协议、§3 文本控制帧
src/net/pose_server.gd
src/autoload/pose_receiver.gd
```
## 交付物
- `src/net/pose_server.gd`:TCPServer + `WebSocketPeer.accept_stream()` 实现服务端
- `src/autoload/pose_receiver.gd`:绑定 AppConfig,暴露 `latest_frame` 与统计
## 能力
| 项 | 说明 |
|---|---|
| 子协议 | 协商 `opendance.pose.v1`,客户端选择不符时告警 |
| 控制帧 | `hello` → 回 `welcome`(含 sessionId);`bye` → 关闭 |
| 协议不匹配 | 回 `bye{reason: protocol_mismatch}` 并以 1002 关闭 |
| 二进制帧 | 严格校验 239 字节与 magic,畸形帧计数但不影响连接 |
| 丢包统计 | 按 `frame_id` 跳号计数(含 u32 回绕保护) |
| 帧率统计 | 1 秒窗口计算 FPS |
| 超时剔除 | 5 秒无数据判定超时断开 |
| config 推送 | 每 2 秒广播 mirror / targetFps / upperBodyOnly / resolution |
## 已知限制
Godot 的 `WebSocketPeer` 不暴露握手请求路径,因此**无法校验 path 是否为 `/pose`**,
实现接受任意 path,仅校验子协议。已记录于 DSH.md 与任务文档。
## 验收
`tests/test_pose_server.gd` 在真实 socket 上端到端验证:握手、子协议协商、
hello/welcome、二进制帧往返、畸形帧拒绝、丢包计数、flag 保留、config 广播、snapshot、stop。
+46
View File
@@ -0,0 +1,46 @@
# T-WIN-004 239 字节帧反序列化
- **状态**:done
- **依赖**:T-WIN-001
## 目标
按契约实现 239 字节关键点帧的编解码。
## load
```
DSH.md
contracts/websocket-keypoints.md # 锚点:§2 二进制帧(冻结)
contracts/skeleton-topology.md # 锚点:§4 坐标系
src/net/keypoint_frame.gd
```
## 帧布局(契约冻结)
```
偏移 长度 字段
0 2 magic u16 = 0x4F44 ('OD')
2 1 version u8 = 1
3 1 flags u8 bit0 镜像 bit1 低置信度 bit2 上半身
4 4 frame_id u32 LE
8 231 keypoints 33 × 7B:s16 x, s16 y, s16 z, u8 conf
合计 239
```
## 交付物
`src/net/keypoint_frame.gd`:`PoseFrame` 内部类 + `decode` / `encode` / `validate` / `from_json_dict`。
## 关键实现
- 归一化系数统一 32767,与 opendance-web 的 TypeScript 参考实现逐位对齐
- 编解码往返误差 < 1e-4(定点精度 1/32767 ≈ 3.05e-5)
- `unmirrored_points()` 按 flags.bit0 翻转 x
- 低置信度阈值 0.5:`is_reliable()` 与 `compute_low_confidence()`
- 极值(±1.0)不溢出 int16
## 验收
`tests/test_keypoint_frame.gd`:常量与契约一致、239 字节、magic/version/flags/frame_id、
往返精度、错误长度/magic/版本拒绝、极值、镜像翻转、置信度阈值、JSON 调试帧。
+41
View File
@@ -0,0 +1,41 @@
# T-WIN-005 VRM 模型加载
- **状态**:done
- **依赖**:T-WIN-001
## 目标
加载 VRM 模型,识别 VRM 版本,定位 Skeleton3D 并映射 humanoid 骨骼。
## load
```
DSH.md
contracts/model-formats.md # 锚点:§1 角色模型
contracts/data-models.md # 锚点:§4.1 VRM 优先规则
src/model/vrm_loader.gd
src/model/humanoid_map.gd
```
## 选型变更
文档原选型 `godot-vrm`(V-Sekai)GDExtension **不发布任何预编译产物**,
需用 godot-cpp 本地编译原生库,会破坏「Linux 导出 Windows exe」的验收路径。
改用 **Godot 内置 GLTFDocument**:VRM 即 glTF 2.0 容器,实测可完整解析
154 骨骼的 VRoid 模型,且 `GLTFState.json` 可直接读取 VRM 扩展判定版本。
## 骨骼名映射
`HumanoidMap` 覆盖 VRoid / VRM 0.x / Mixamo 三类命名,归一化后精确比较:
- 剥离前缀 `jbip` / `mixamorig` / `bip01`(**归一化之后**的形态)
- 生成 compact(left→l)与 expanded(l→left)两种变体比较
- **故意不剥离 `J_Aim_` / `J_Roll_` / `J_Sec_`**:VRoid 用它们标记扭转辅助骨,
剥离后 `J_Aim_L_UpperLeg` 会与真正的 `J_Bip_L_UpperLeg` 撞名
## 验收
`tests/test_vrm_loader.gd`:
- 三类命名规范的识别(含辅助骨不遮蔽真骨的回归用例)
- 真实 VRM:154 骨骼 / 3 网格 / VRM 1.0,**humanoid 覆盖率 100%**
- 缺失文件、普通 glTF 不误判
+45
View File
@@ -0,0 +1,45 @@
# T-WIN-006 关键点 → VRM 骨骼重定向
- **状态**:done
- **依赖**:T-WIN-005、T-WIN-004
## 目标
把 33 点关键点实时驱动到 VRM 骨骼上。
## load
```
DSH.md
contracts/skeleton-topology.md # 锚点:§1 33 点、§2 虚拟关节、§4 坐标系
src/anim/skeleton_retarget.gd
```
## 算法
1. 每根骨骼指定一条骨链(起点关键点,终点关键点)
2. 取骨骼静止朝向 `rest_dir`(由该骨与首个子骨的位置差得出,**不假设模型骨骼轴向**)
3. 目标朝向由两个关键点在世界空间的方向得出
4. `delta` = rest_dir → target_dir 的最小旋转四元数(保留扭转/roll)
5. `desired = Basis(delta) × rest_global_basis`
6. `pose_local = parent_global_basis⁻¹ × desired`
**第 6 步的合成公式是实测标定的**:Godot 文档未写明 rest/pose 的合成顺序,
在真实 VRoid 模型上暴力枚举 5 种候选公式,只有该式误差为 1.9e-8(其余 ~0.14)。
已有回归测试守护,不得凭记忆改写。
## 坐标转换
契约 §4 的 MediaPipe 归一化坐标 → Godot 世界方向:
`Vector3(x × aspect, −y, −z × aspect)`(x/z 以图像宽为基准故乘宽高比)。
## 验收
`tests/test_retarget.gd` —— 最关键的是**功能性**断言:
```
→ 左上臂方向点积 1.0000,左前臂方向点积 1.0000
```
即:给定一组关键点,重定向后骨骼在世界空间的**实际指向**与关键点方向一致;
另有判别性验证(换一组关键点骨骼朝向随之改变)与躯干 FK 传播验证。
+42
View File
@@ -0,0 +1,42 @@
# T-WIN-007 DTW 评分引擎
- **状态**:done
- **依赖**:T-WIN-004、T-WIN-002
## 目标
用 DTW 把实时关键点序列与服务端参考序列比对,产出 0~100 分与等级。
## load
```
DSH.md
contracts/skeleton-topology.md # 锚点:§5 派生特征
contracts/model-formats.md # 锚点:§2.1 预处理产物
src/score/pose_features.gd
src/score/dtw.gd
src/score/scoring.gd
```
## 特征(契约 §5 冻结)
每帧 = `[33 × (x,y,z), 9 个关节角余弦]` = **108 维**。
低置信度点(conf < 0.5)对应维度**权重置 0**,不参与代价计算。
## DTW
- Sakoe-Chiba 带状约束 `|i − j| ≤ band`(默认 15 帧 ≈ 0.5 秒)
- `DtwMatcher.compute_full()` 离线段;`StreamingDtw` 逐帧流式推进,
每帧只计算带内一行,总体复杂度 O(n·m·band) 但摊到每帧 O(m·band)
- 归一化:代价 / max(i,j)+1,得到「平均每步代价」
## 评分规则(`scoring.gd`)
`score = clamp(1 − cost/tolerance, 0, 1) × 100`,EMA 平滑抑制抖动,
等级 S(≥95) / A(≥85) / B(≥70) / C(≥55) / D,连击阈值 70。
## 验收
`tests/test_dtw.gd` + `tests/test_pose_features.gd`:相同序列距离为 0、
路径单调且起于 (0,0)、带宽约束、时间伸缩吸收、流式与离线一致、
低置信度维度不参与距离、角度计算正确性。
+40
View File
@@ -0,0 +1,40 @@
# T-WIN-008 主界面 + 游戏界面 + 结算
- **状态**:done
- **依赖**:T-WIN-002、T-WIN-003、T-WIN-006、T-WIN-007
## 目标
三页式体验:主菜单(选曲/选动作/选模型)→ 游戏页(3D 角色 + 实时评分 HUD)→ 结算页。
## load
```
DSH.md
src/ui/main.gd
src/ui/main_menu.gd
src/ui/game_screen.gd
src/ui/result_screen.gd
src/ui/ui_kit.gd
```
## 页面
| 页面 | 能力 |
|---|---|
| 主菜单 | 三栏选择(曲目 / 可评分动作 / 模型)、服务端地址编辑与测试、目录刷新、接收服务状态与重启 |
| 游戏页 | SubViewport 3D 角色、自动定机位、实时分数/等级/连击、进度条、连接状态提示、来源展示 |
| 结算 | 等级大字、平均/最终分、完成度、最高单帧、最长连击、评语、来源展示 |
## 关键实现
- 界面全部代码构建(见 DSH.md §6),主菜单只列出 `preprocessState == "done"` 的动作
- 游戏页订阅 `PoseReceiver.frame_received` 驱动骨骼,`ScoreService` 独立订阅做评分
- 未连接设备时给出明确提示而非静默失败;模型/序列拉取失败时显示可读原因并允许重试
- 参考序列走完自动结算
- 切换模型时正确 `dispose()` 旧场景,避免泄漏
## 验收
`GODOT --headless --path . --quit-after 180` 启动无运行时错误;
主菜单、游戏页、结算页的构造路径均被该启动流程覆盖。
+40
View File
@@ -0,0 +1,40 @@
# T-WIN-009 来源信息展示
- **状态**:done
- **依赖**:T-WIN-008
## 目标
在客户端展示模型/动作的**来源五元组**(来源类型、名称、链接、许可、作者)。
## load
```
DSH.md
contracts/data-models.md # 锚点:§1 来源五元组
src/ui/source_panel.gd
src/net/catalog_types.gd
```
## 交付物
`src/ui/source_panel.gd`(可复用组件)+ `CatalogTypes.SourceInfo`(含 `type_label()` 与 `is_complete()`)。
## 展示规则
- 标题 + 来源类型中文标签(原创 / 已获授权 / Creative Commons / 公共领域 / 用户上传 / 其他)
- 资产名 | 来源名称
- 作者
- 许可 + **可点击外链**跳转到 `sourceUrl`
- **五元组不齐备时显式告警**("⚠ 来源信息不完整,请联系管理员补全后再使用"),不静默展示
## 使用位置
- 主菜单:选中动作时显示
- 游戏页:当前跟练动作
- 结算页:本轮动作
## 验收
`tests/test_api_integration.gd` 对真实服务端断言曲目与模型的来源五元组齐备
(`song.source.is_complete()`),保证展示组件拿到的数据完整。
+34
View File
@@ -0,0 +1,34 @@
# T-WIN-010 关于页面 + 开源声明
- **状态**:done
- **依赖**:T-WIN-008
## 目标
关于页面展示版本信息与**完整可滚动**的第三方开源项目声明。
## load
```
DSH.md
contracts/open-source-credits.md # 锚点:全表
src/ui/about_screen.gd
src/ui/credits.gd
```
## 交付物
- `src/ui/credits.gd`:与 `contracts/open-source-credits.md` 逐条对应的数据表,渲染为 BBCode
- `src/ui/about_screen.gd`:应用信息 + 契约版本 + 隐私说明 + 可滚动声明(RichTextLabel)
## 要点
- 声明含每项的**许可证、版权(如有)、用途、可点击源链接**
- 明确声明不使用 SMPL 拓扑
- 明确声明「仅接收关键点,不接收、不存储视频画面」
- 与 `THIRD_PARTY.md`、`contracts/open-source-credits.md` 三处保持一致
## 验收
`tests/test_credits.gd` 断言:条目数一致、每条均有名称/许可证/https 链接、
BBCode 含全部条目、纯文本导出可解析。
+58
View File
@@ -0,0 +1,58 @@
# T-WIN-011 Windows 打包脚本
- **状态**:done
- **依赖**:T-WIN-001 ~ 010
## 目标
提供 Linux 直接导出(方案 A,推荐)与 Windows 端兜底(方案 B)两条打包路径。
## load
```
DSH.md
build/export-windows.sh
build/export-windows.ps1
export_presets.cfg
```
## 交付物
| 文件 | 说明 |
|---|---|
| `export_presets.cfg` | 导出预设(**必须在项目根目录**,Godot 硬性要求) |
| `build/export-windows.sh` | Linux 导出:定位 Godot → 检查模板 → 导入 → 导出 → **校验 PE 头** |
| `build/export-windows.ps1` | Windows 兜底:同上,产物路径与校验逻辑一致 |
## 脚本健壮性
- `GODOT_BIN` 环境变量或 PATH 自动定位 Godot,缺失时给出可执行的安装指引
- 导出模板缺失时明确报错并给出下载与安装路径
- 导出命令返回非 0 时**仍检查产物是否存在**(Godot 有时非 0 返回但产物有效)
- 读取产物前 2 字节校验 `MZ`(PE 头),防止导出静默产出空壳
## 验收(文档 §5.4)
| 验收项 | 结果 |
|---|---|
| Linux:`bash build/export-windows.sh` 成功生成 exe | ✅ 生成 `OpenDance.exe` 104 MB + `OpenDance.pck` 107 KB,PE 头校验通过 |
| Windows:`powershell build/export-windows.ps1` 成功生成 exe | ⚠️ 脚本就绪,本环境无 Windows,未实机执行 |
| 生成的 exe 在 Windows 10/11 可运行 | ⚠️ 本环境无 Windows;已校验为合法 PE32+ x86_64 可执行文件 |
| `THIRD_PARTY.md` 包含所有开源项目与源链接 | ✅ 15 项,与契约表逐条一致 |
## 打包产物
```
opendance-windows-v0.1.0.zip
├── OpenDance.exe
├── OpenDance.pck
├── THIRD_PARTY.md
├── README.txt
└── contracts/ # 契约快照
```
## 环境记录
- Godot 4.7.2.stable(`ed1daf0bf`)
- 导出模板 `4.7.2.stable`(``windows_release_x86_64.exe``)
- 首次导出失败原因:`export_presets.cfg` 放在 `build/` 下,Godot 只在**项目根目录**查找