feat(scene): 场景选择与物理落地 + 整包发布(T-WIN-012/013/014)

T-WIN-013:API 升级与场景选择
- 契约快照同步到 rest-api 0.5.0 / data-models 0.7.0 / database 0.4.0
- catalog_types.gd 新增 SceneConfig(物理/环境/出生点/相机)与 Scene;
  CatalogBundle 增加 scenes[];from_dict 按契约 7.1 再收敛一次
  (缺项默认、越界夹紧、非法枚举回退),与服务端 normalizeSceneConfig 同规则
- 新增 src/scene/scene_runtime.gd:GLB 载入 -> 碰撞体(mesh 三角网 /
  auto 命名约定 *_col、*_colonly / none 不兜底)-> 场景级重力(Area3D
  SPACE_OVERRIDE_REPLACE,不改全局 ProjectSettings)-> 风(每物理帧
  apply_central_force,阵风正弦叠加,支持 local 坐标系)-> 环境光与雾
  -> 出生点与相机(follow/fixed/orbit);无碰撞体时兜底地面
- CatalogStore 增加 scenes/default_scene/find_scene/ensure_scene_cached/
  fetch_thumbnail(场景缓存带 sha256 指纹,避免换包后仍用旧 GLB)
- 主界面改为 2x2 四个选择器(曲目/动作/模型/场景)+ 场景参数面板
  (缩略图 + 重力/摩擦/弹性/碰撞体/风摘要)
- 游戏界面载入场景并落地物理,角色落在出生点;场景缺失/下载失败/
  解析失败一律回退内置空场景并提示,不阻塞开始跟练

T-WIN-012:「关于」清单展示当前版本号
- 版本唯一来源为 project.godot 的 application/config/version(v0.2.0)
- about_manifest() 输出版本/引擎/契约/协议/地址/隐私清单;
  无头测试无 autoload,读取 AppConfig 走 get_node_or_null 容错
- Credits 的单个 CONTRACT_VERSION 常量改为 CONTRACT_VERSIONS 字典
  (六项契约各自独立递增),并双向核对 contracts/VERSION

T-WIN-014:发布必须是单个完整包
- build/package-release.sh 重写:只产出单个 zip + SHA256SUMS,
  清理历史 .partN,超限时告警而非回退分卷;新增源产物与解压后完整性
  自检(PE 头 MZ / pck 魔数 GDPC / exe+pck 同目录同名)与体积-上限对比
- deploy/proxy 相关的 Gitea IIS 256MB 上限说明见 opendance-web

测试:tests/test_scene_config.gd(12 例)+ tests/test_about.gd(4 例);
无头测试 687 通过 / 0 失败(10 个套件),verify.sh 全部通过
This commit is contained in:
2026-09-17 00:45:23 +08:00
parent 47e11af7d0
commit 5581cbe05d
27 changed files with 2031 additions and 85 deletions
+1 -1
View File
@@ -66,7 +66,7 @@ opendance-windows/
├── export_presets.cfg # ★ 必须位于项目根目录(Godot 硬性要求)
├── project.yaml / DSH.md
├── tasks/ # 001~011 单任务说明书
├── contracts/ # 只读契约快照(来源 opendance-web v0.1.0)
├── contracts/ # 只读契约快照(来源 opendance-web;六项契约各自独立版本)
├── scenes/main.tscn # 仅一个挂载 main.gd 的根节点
├── src/
│ ├── autoload/ # AppConfig / CatalogStore / PoseReceiver / ScoreService
+37 -11
View File
@@ -9,17 +9,20 @@ OpenDance Studio **阶段二**: Windows 客户端(Godot 4)。
## 状态
**阶段二已完成**: T-WIN-001 -> T-WIN-011 全部落地。
**阶段二已完成**: T-WIN-001 -> T-WIN-014 全部落地(含场景选择与整包发布)。
| 验收项 | 结果 |
|--|--|
| 脚本编译 | OK 全部通过(无 SCRIPT ERROR) |
| 无头测试 | OK **580 项全部通过**(8 个测试套件) |
| 无头测试 | OK **687 项全部通过**(10 个测试套件) |
| 应用启动 | OK 180 帧无运行时错误 |
| Windows 导出 | OK 生成 `OpenDance.exe`(104 MB, PE 头校验通过)+ `OpenDance.pck` |
| 真实 VRM 验证 | OK 154 骨骼 VRoid 模型,humanoid 覆盖率 **100%** |
| 骨骼重定向 | OK 关键点方向与骨骼实际指向点积 **1.0000** |
| 跨实现交叉校验 | OK 关节角与 Web 服务端 TypeScript 实现**逐帧一致** |
| 场景选择与物理落地 | OK `scenes[]` 可选、重力/风/摩擦/碰撞体落地并可日志核对 |
| 发布整包 | OK 单个 `opendance-windows-v0.2.0.zip` + `SHA256SUMS`(不拆分卷) |
| 版本号展示 | OK 「关于」清单显示 `v0.2.0`,来源唯一(`project.godot`) |
## 快速开始
@@ -30,10 +33,13 @@ GODOT_BIN=/path/to/godot bash build/verify.sh
# 导出 Windows 可执行文件
GODOT_BIN=/path/to/godot bash build/export-windows.sh release
# 打包发布产物
bash build/package-release.sh 0.1.0
# 打包发布产物(单个完整 zip + SHA256SUMS)
bash build/package-release.sh 0.2.0
```
当前版本:**v0.2.0**(`project.godot` 的 `application/config/version`,
与 `project.yaml`、导出产物名、Git tag、「关于」清单展示一致)。
详细使用说明见 [`README.txt`](README.txt),工程约束见 [`DSH.md`](DSH.md)。
## 初次部署(从零跑起来)
@@ -90,7 +96,10 @@ http://192.0.2.10:3000
| 对端 | 方向 | 本端配置 | 说明 |
|--|--|--|--|
| Android 辅助 App | Android -> 本端(WebSocket) | `listen_host`(默认 `0.0.0.0`)、`listen_port`(默认 **8765**)、`ws_subprotocol`(默认 `opendance.pose.v1`) | 本端是 WebSocket **服务端**; Android 端填写本端主界面显示的「接收地址」,形如 `ws://<本机局域网地址>:8765/pose` |
| Web 服务端 | 本端 -> Web(HTTP) | `api_base_url`(默认 `http://127.0.0.1:3000`) | 需先在服务端**发布**曲目 / 动作 / 模型,动作还须**完成关键点预处理**,否则目录为空或动作不可选 |
| Web 服务端 | 本端 -> Web(HTTP) | `api_base_url`(默认 `http://127.0.0.1:3000`) | 需先在服务端**发布**曲目 / 动作 / 模型 / 场景,动作还须**完成关键点预处理**,否则目录为空或该项不可选 |
> **场景不是必需项**:服务端没发布任何可用场景(或缺 GLB)时,本端回退到内置空场景
> 并在界面提示,**不会阻塞开始跟练**(文档 §5.6 第 4 条)。
- **Android 侧无本端可配置项**:接收地址由 Android App 自己填写;本端只监听固定端口 8765(可在 `opendance.cfg` 改 `listen_port` 后点「重新连接接收服务」生效)。
- 已知限制:Godot 的 `WebSocketPeer` 不暴露握手请求路径,本端**接受任意 path**,只校验子协议 `opendance.pose.v1`(见「已知限制」1)。
@@ -105,8 +114,8 @@ GODOT_BIN=/path/to/godot bash build/export-windows.sh release
# Windows 上兜底导出
powershell -ExecutionPolicy Bypass -File build\export-windows.ps1
# 打成可分发的 zip(可选)
bash build/package-release.sh 0.1.0
# 打成可分发的 zip(单个完整包 + SHA256SUMS)
bash build/package-release.sh 0.2.0
```
| 产物 | 位置 | 说明 |
@@ -114,7 +123,8 @@ bash build/package-release.sh 0.1.0
| `OpenDance.exe` | `build/windows/` | 主程序,约 104 MB;脚本校验前 2 字节为 `MZ`(合法 PE) |
| `OpenDance.pck` | `build/windows/` | 资源包,约 107 KB; **必须与 exe 同目录** |
| `OpenDance.console.exe` | `build/windows/` | 控制台包装器,可能生成,非必需 |
| `opendance-windows-v0.1.0.zip` | `dist-release/` | 发布包:exe + pck + `THIRD_PARTY.md` + `README.txt` + `contracts/` |
| `opendance-windows-v0.2.0.zip` | `dist-release/` | 发布包:exe + pck + `THIRD_PARTY.md` + `README.txt` + `README.md` + `contracts/` |
| `SHA256SUMS` | `dist-release/` | 裸文件名,与 zip 同目录时可 `sha256sum -c SHA256SUMS` 直接校验 |
导出前必须安装与编辑器同版本的 export templates,否则脚本会报「未找到导出模板」。
@@ -123,7 +133,7 @@ bash build/package-release.sh 0.1.0
| 检查 | 命令 | 预期输出 |
|--|--|--|
| 一键验证(编译 + 测试 + 启动) | `GODOT_BIN=/path/to/godot bash build/verify.sh` | 末行「验证全部通过」,退出码 0 |
| 无头单元 / 集成测试 | `$GODOT --headless --path . --script res://tests/run_tests.gd` | `== 结果:580 通过 / 0 失败 ==` 与 `全部通过`(仓库基线 580 项 / 8 个套件;缺 VRM 素材或服务端不可达时相关用例显式跳过,数字会减少) |
| 无头单元 / 集成测试 | `$GODOT --headless --path . --script res://tests/run_tests.gd` | `== 结果:687 通过 / 0 失败 ==` 与 `全部通过`(仓库基线 687 项 / 10 个套件;缺 VRM 素材或服务端不可达时相关用例显式跳过,数字会减少) |
| 启动冒烟(180 帧) | `$GODOT --headless --path . --quit-after 180` | 无 `SCRIPT ERROR` / `ERROR:`,进程退出码 0 |
| 日志检查 | 打开 `%APPDATA%\Godot\app_userdata\OpenDance Studio\logs\godot.log` | 无 `SCRIPT ERROR`、无 `ERROR:` |
| 导出产物校验 | `bash build/export-windows.sh release` | 输出「PE 头校验通过」,且 `build/windows/OpenDance.exe` 前 2 字节为 `4d5a` |
@@ -165,19 +175,30 @@ src/
├── net/ 239 字节编解码、WebSocket 服务、REST 客户端、NDJSON 解析
├── model/ VRM 加载、humanoid 骨骼名映射
├── anim/ 关键点 -> 骨骼重定向
├── scene/ 场景实例化与物理落地(重力/风/碰撞/环境/机位)
├── score/ 特征提取、DTW、评分规则
└── ui/ 主界面 / 游戏界面 / 结算 / 关于 / 来源展示
└── ui/ 主界面 / 游戏界面 / 结算 / 关于 / 来源展示 / 场景参数面板
```
界面**全部用 GDScript 代码构建**(不手写 `.tscn`),因此可被无头导入与单元测试覆盖。
## 依赖的契约(opendance-web v0.1.0 冻结)
## 依赖的契约(opendance-web 冻结,逐项独立版本)
| 契约 | 版本 |
|--|--|
| `rest-api` | 0.5.0 |
| `data-models` | 0.7.0 |
| `websocket-keypoints` | 0.1.0 |
| `skeleton-topology` | 0.2.0 |
| `model-formats` | 0.5.0 |
| `database` | 0.4.0 |
- [`contracts/websocket-keypoints.md`](contracts/websocket-keypoints.md) - 239 字节关键点帧与文本控制消息(本端为**接收方**)
- [`contracts/skeleton-topology.md`](contracts/skeleton-topology.md) - MediaPipe 33 点拓扑、父节点表、9 个关节角特征
- [`contracts/rest-api.yaml`](contracts/rest-api.yaml) - 目录与关键点序列 API
- [`contracts/data-models.md`](contracts/data-models.md) - 资源字段与 VRM 优先规则
- [`contracts/model-formats.md`](contracts/model-formats.md) - 模型与动作格式、关键点序列结构
- [`contracts/database.md`](contracts/database.md) - 数据库表结构(客户端只读快照,不直连数据库)
`contracts/` 是**只读快照**。任何跨端契约变更必须先回到
[`opendance-web`](https://<Gitea 地址>/<owner>/opendance-web) 修改并提升版本,
@@ -193,6 +214,11 @@ src/
两条路径共用同名导出预设与相同的 PE 头校验逻辑。
4. 真实 VRM 样例仅放在 `_scratch/`(已 gitignore),版权原因不入库;
测试在找不到模型时显式跳过而非伪装通过。
5. **Gitea 附件上传上限**:本项目 Gitea 前置 IIS 的默认
`maxAllowedContentLength = 30,000,000` 字节(≈28.6 MiB),实测 30.5 MB / 40 MB
上传返回 HTTP 413。`v0.1.0` 因此被拆成 `zip.part0/1/2`(无法直接解压)。
现改为**只上传单个完整包**,超限时打包脚本直接告警并要求运维放宽上限,
不再回退分卷;做法见 `opendance-web/deploy/proxy/README.md`。
## 第三方开源声明
+52 -18
View File
@@ -1,5 +1,5 @@
================================================================
OpenDance Studio -- Windows 客户端 v0.1.0 使用说明
OpenDance Studio -- Windows 客户端 v0.2.0 使用说明
================================================================
摄像头跳舞评分系统的核心体验端:接收 Android 辅助 App 通过 WebSocket
@@ -16,8 +16,9 @@ OpenDance Studio -- Windows 客户端 v0.1.0 使用说明
OpenDance.exe 主程序
OpenDance.pck 资源包(必须与 exe 同目录)
README.txt 本文件
README.md 同内容 Markdown 版
THIRD_PARTY.md 开源声明
contracts/ 契约快照
contracts/ 契约快照(只读;客户端不直连数据库)
2. 先启动 Web 管理服务端(opendance-web),确保能访问其 API。
@@ -26,13 +27,15 @@ OpenDance Studio -- Windows 客户端 v0.1.0 使用说明
4. 在主界面确认「服务端地址」(默认 http://127.0.0.1:3000),
点击「保存并测试」;顶部应显示「服务端正常 | DB...」。
5. 点击「刷新目录」,左侧会出现已发布的曲目、动作与模型。
5. 点击「刷新目录」,左侧会出现已发布的曲目、动作、模型与场景。
6. 在 Android 手机上启动「OpenDance 辅助 App」,填入本机显示在
主界面上的接收地址(形如 ws://<本机局域网地址>:8765/pose)并连接。
连接成功后主界面会提示「已连接 N 台设备」。
7. 依次选择曲目 -> 动作 -> 模型,点击「开始跟练」。
7. 依次选择曲目 -> 动作 -> 模型 -> 场景,点击「开始跟练」。
场景可以留空不选:服务端没有可用场景时会自动回退到内置空场景,
界面会提示但不影响开始跟练。
--
二、初次部署(从零跑起来)
@@ -98,19 +101,29 @@ OpenDance.exe 的用户看第一节即可。
+ OpenDance.pck(约 107 KB,必须与 exe 同目录);可能附带
OpenDance.console.exe。
打成发布包(可选):
打成发布包:
bash build/package-release.sh 0.1.0
bash build/package-release.sh 0.2.0
得到 dist-release/opendance-windows-v0.1.0.zip,内含 OpenDance.exe、
OpenDance.pck、THIRD_PARTY.md、README.txt、contracts/。
得到 dist-release/ 下两个资产:
opendance-windows-v0.2.0.zip <- 单个完整包,禁止 .partN 分卷
SHA256SUMS <- 裸文件名,可直接 sha256sum -c
包里含 OpenDance.exe、OpenDance.pck、THIRD_PARTY.md、README.txt、
README.md、contracts/。打包脚本会解压自检(exe + pck 必须同目录、
exe 的 PE 头为 MZ、pck 魔数为 GDPC)并打印包体与上传上限的对比。
注意:本项目 Gitea 前置 IIS 的默认上传上限是 30,000,000 字节
(约 28.6 MiB),超过会返回 HTTP 413。这时不要改成分卷,
请让运维放宽上限(做法见 opendance-web/deploy/proxy/README.md)。
7. 自检清单
1) 一键验证:GODOT_BIN=/path/to/godot bash build/verify.sh
预期末行「验证全部通过」。
2) 无头测试:$GODOT --headless --path . --script res://tests/run_tests.gd
预期「== 结果:580 通过 / 0 失败 ==」与「全部通过」(缺 VRM 素材或
预期「== 结果:687 通过 / 0 失败 ==」与「全部通过」(缺 VRM 素材或
服务端不可达时相关用例跳过,数字会减少)。
3) 启动冒烟:$GODOT --headless --path . --quit-after 180
预期无 SCRIPT ERROR / ERROR:,退出码 0。
@@ -142,22 +155,33 @@ OpenDance.exe 的用户看第一节即可。
--
主界面
- 三栏选择:曲目 / 动作(仅列出已完成预处理的动作)/ 模型(VRM 优先)
- 四个选择器(2x2 栅格):曲目 / 动作(仅列出已完成预处理的动作)/
模型(VRM 优先)/ 场景(GLB + 物理参数)
- 服务端地址:可修改并即时测试
- 接收地址:Android 端要连接的 WebSocket 地址
- 「刷新目录」重新拉取已发布资源
- 底部展示所选动作的**来源信息**(来源类型、名称、作者、许可、链接)
- 「刷新目录」重新拉取已发布资源(含场景)
- 底部左侧展示所选动作的来源信息(来源类型、名称、作者、许可、链接)
- 底部中间展示所选场景的缩略图与物理参数摘要
(重力 / 摩擦 / 弹性 / 碰撞体来源 / 风);未选场景时说明将使用内置空场景
游戏界面
- 左侧 3D 视图:VRM 角色跟随关键点实时动作,相机自动框住模型
- 右侧 HUD:实时得分、等级、连击、动作进度、连接状态、来源信息
- 左侧 3D 视图:VRM 角色落在场景出生点并跟随关键点实时动作,
相机按场景配置取景(follow 跟随 / fixed 固定 / orbit 环绕)
- 场景物理在本端落地:重力(Area3D 覆盖,不改全局 ProjectSettings)、
风(每物理帧施加风力,阵风按正弦叠加)、地面摩擦与弹性、
GLB 碰撞体(mesh 三角网 / auto 命名约定 *_col、*_colonly)、环境光与雾
- 右侧 HUD:实时得分、等级、连击、动作进度、连接状态、来源信息、场景参数
结算界面
- 等级(S/A/B/C/D)、平均分与最终分、动作完成度、最高单帧、最长连击
- 「再来一次」/「返回主菜单」/「开源声明」
关于页面
- 版本与契约版本、隐私说明、完整可滚动的第三方开源项目声明
- 「清单」逐行显示:客户端版本(vX.Y.Z)/ 运行引擎 / 契约版本 /
接收协议 / 接收地址 / 服务端地址 / 隐私说明
- 版本号唯一来源是 project.godot 的 application/config/version,与
project.yaml、导出产物名、Git tag、界面展示完全一致
- 完整可滚动的第三方开源项目声明
--
四、配置文件
@@ -230,15 +254,16 @@ OpenDance.exe 的用户看第一节即可。
export_presets.cfg 导出预设(必须在项目根目录)
project.yaml 任务清单与状态
DSH.md 本项目 DSH 执行约束
tasks/ 单任务说明书(T-WIN-001 ~ 011)
contracts/ 只读契约快照(来源 opendance-web v0.1.0)
tasks/ 单任务说明书(T-WIN-001 ~ 014)
contracts/ 只读契约快照(来源 opendance-web;六项契约各自独立版本)
scenes/main.tscn 根节点
src/autoload/ AppConfig / CatalogStore / PoseReceiver / ScoreService
src/net/ 239 字节编解码、WebSocket 服务、REST 客户端
src/model/ VRM 加载、humanoid 骨骼映射
src/anim/ 关键点 -> 骨骼重定向
src/scene/ 场景实例化与物理落地(重力/风/碰撞/环境/机位)
src/score/ 特征提取、DTW、评分规则
src/ui/ 主界面 / 游戏界面 / 结算 / 关于 / 来源展示
src/ui/ 主界面 / 游戏界面 / 结算 / 关于 / 来源展示 / 场景参数
tests/ 无头测试框架与用例
build/ 导出脚本
@@ -260,6 +285,15 @@ A: 游戏界面会显示「人形覆盖率」。低于 60% 说明模型的骨骼
Q: 连上了手机但分数一直是 0
A: 确认手机端已开始推流;主界面的「查看接收状态」会显示累计帧数与丢帧数。
Q: 选场景时提示「场景缺少 GLB 文件」或「已回退内置空场景」
A: 服务端该场景没有可下载的 GLB(转换失败或未发布)。这不影响开始跟练,
会使用内置空场景与默认物理参数;请让管理员在管理端「场景管理」重新上传。
Q: 场景里的角色一直往下掉
A: 场景没有可用碰撞体且配置为 colliders=auto/mesh。本端会自动生成兜底地面
(日志会记录「已生成兜底地面」);若场景显式配置 colliders=none,则按场景
作者意图不生成任何碰撞体,此时角色不会被地面接住。
Q: 关于页面的开源声明
A: 与 THIRD_PARTY.md、contracts/open-source-credits.md 三处保持一致。
+1 -1
View File
@@ -1,6 +1,6 @@
# 第三方开源项目声明
**OpenDance Studio Windows 客户端 v0.1.0**
**OpenDance Studio Windows 客户端 v0.2.0**
本软件使用了以下开源项目,特此声明。完整锚点见 `contracts/open-source-credits.md`
(三端共用,任一端新增依赖须回到 `opendance-web` 更新该表)。
+91 -15
View File
@@ -1,14 +1,27 @@
#!/bin/bash
# ============================================================
# OpenDance Studio —— Windows 客户端发布打包
# OpenDance Studio —— Windows 客户端发布打包(T-WIN-014)
#
# 产物(文档 §5.3):
# opendance-windows-v0.1.0.zip
# ├── OpenDance.exe
# ├── OpenDance.pck
# ├── THIRD_PARTY.md
# ├── README.txt
# └── contracts/ 契约快照
# 产物(文档 §5.3 / §5.7):
# opendance-windows-vX.Y.Z.zip ← **单个完整包,禁止 .partN 分卷**
# SHA256SUMS ← 裸文件名,下载目录内 sha256sum -c 直接可用
#
# 包内结构:
# opendance-windows-vX.Y.Z/
# OpenDance.exe
# OpenDance.pck
# THIRD_PARTY.md
# README.txt README.md
# contracts/
#
# 为什么强调"单个完整包":本项目的 Gitea 前置 IIS 默认
# maxAllowedContentLength = 30,000,000 字节(≈28.6 MiB),
# 30.5 MB / 40 MB 的上传实测直接 HTTP 413。当时的应对是把整包拆成
# zip.part0/1/2(每卷 13 MiB)——这正是"分卷有问题"的根因:
# 用户拿到的是无法直接解压的碎片,还要自己拼接。
# 现在改为:整包上传;若超过上限则**直接报错退出**,并提示运维放宽
# IIS/ARR 上限(做法见 opendance-web/deploy/proxy/README.md),
# 绝不静默退回分卷。
#
# 用法:bash build/package-release.sh [版本号]
# ============================================================
@@ -17,12 +30,17 @@ set -euo pipefail
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
cd "$PROJECT_DIR"
VERSION="${1:-0.1.0}"
VERSION="${1:-$(sed -n 's/^version:[[:space:]]*//p' project.yaml | head -1)}"
VERSION="${VERSION:-0.2.0}"
NAME="opendance-windows-v${VERSION}"
OUT_DIR="$PROJECT_DIR/dist-release"
STAGE="$OUT_DIR/$NAME"
# Gitea 前置 IIS 的实测上传上限(文档 §5.7 实测结论)
UPLOAD_LIMIT_BYTES=30000000
log() { printf '\033[1;34m[package]\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m[warn]\033[0m %s\n' "$*" >&2; }
die() { printf '\033[1;31m[error]\033[0m %s\n' "$*" >&2; exit 1; }
EXE="$PROJECT_DIR/build/windows/OpenDance.exe"
@@ -31,10 +49,14 @@ PCK="$PROJECT_DIR/build/windows/OpenDance.pck"
[ -f "$EXE" ] || die "未找到 $EXE,请先执行 bash build/export-windows.sh release"
[ -f "$PCK" ] || die "未找到 $PCK,请先执行 bash build/export-windows.sh release"
# 校验 PE 头
# ---- 1. 源产物自检:PE 头 + pck 魔数 ----
MAGIC=$(head -c 2 "$EXE" | od -An -tx1 | tr -d ' \n')
[ "$MAGIC" = "4d5a" ] || die "$EXE 不是合法的 Windows PE 文件(头 $MAGIC)"
PCK_MAGIC=$(head -c 4 "$PCK" | tr -d '\0')
[ "$PCK_MAGIC" = "GDPC" ] || die "$PCK 不是合法的 Godot 资源包(头 '$PCK_MAGIC')"
log "源产物校验通过:exe PE 头 MZ | pck 头 GDPC"
# ---- 2. 整理产物 ----
log "整理产物到 $STAGE …"
rm -rf "$STAGE"
mkdir -p "$STAGE"
@@ -42,14 +64,68 @@ mkdir -p "$STAGE"
cp "$EXE" "$STAGE/OpenDance.exe"
cp "$PCK" "$STAGE/OpenDance.pck"
cp THIRD_PARTY.md README.txt "$STAGE/"
if [ -f README.md ]; then cp README.md "$STAGE/"; fi
cp -r contracts "$STAGE/contracts"
log "生成压缩包…"
# ---- 3. 打包(单个完整包) ----
ZIP="$OUT_DIR/$NAME.zip"
# 清理历史分卷,避免 old .partN 被误当成本次产物上传
rm -f "$OUT_DIR/$NAME.zip".part* "$OUT_DIR/$NAME.zip" "$OUT_DIR/SHA256SUMS"*
log "生成单个完整包 $NAME.zip …"
( cd "$OUT_DIR" && zip -qr "$NAME.zip" "$NAME" )
log "完成:$OUT_DIR/$NAME.zip($(du -h "$OUT_DIR/$NAME.zip" | cut -f1))"
# ---- 4. 完整性自检:解压后必须含 exe + pck(同目录)且 exe 仍是合法 PE ----
CHECK_DIR=$(mktemp -d)
trap 'rm -rf "$CHECK_DIR"' EXIT
unzip -q "$ZIP" -d "$CHECK_DIR"
[ -f "$CHECK_DIR/$NAME/OpenDance.exe" ] || die "包内缺少 OpenDance.exe"
[ -f "$CHECK_DIR/$NAME/OpenDance.pck" ] || die "包内缺少 OpenDance.pck(必须与 exe 同目录)"
[ -f "$CHECK_DIR/$NAME/README.txt" ] || die "包内缺少 README.txt"
[ -f "$CHECK_DIR/$NAME/THIRD_PARTY.md" ] || die "包内缺少 THIRD_PARTY.md"
[ -d "$CHECK_DIR/$NAME/contracts" ] || die "包内缺少 contracts/ 契约快照"
CHECK_MAGIC=$(head -c 2 "$CHECK_DIR/$NAME/OpenDance.exe" | od -An -tx1 | tr -d ' \n')
[ "$CHECK_MAGIC" = "4d5a" ] || die "解压后的 OpenDance.exe 不是合法 PE 文件(头 $CHECK_MAGIC)"
CHECK_PCK=$(head -c 4 "$CHECK_DIR/$NAME/OpenDance.pck" | tr -d '\0')
[ "$CHECK_PCK" = "GDPC" ] || die "解压后的 OpenDance.pck 不是合法 Godot 资源包"
log "整包完整性自检通过(解压后 exe + pck 同目录,PE/GDPC 头均合法)"
# pck 与 exe 必须同名同目录,否则 Godot 找不到资源
[ "$(basename "$CHECK_DIR/$NAME/OpenDance.exe" .exe)" = "$(basename "$CHECK_DIR/$NAME/OpenDance.pck" .pck)" ] \
|| die "exe 与 pck 主文件名不一致,导出产物不可运行"
# ---- 5. SHA256SUMS(裸文件名,下载目录内可直接校验) ----
( cd "$OUT_DIR" && sha256sum "$NAME.zip" > SHA256SUMS )
log "已生成 $OUT_DIR/SHA256SUMS"
# ---- 6. 体积与上传上限对比 ----
SIZE_BYTES=$(stat -c %s "$ZIP")
SIZE_HUMAN=$(du -h "$ZIP" | cut -f1)
log "包体 $SIZE_HUMAN($SIZE_BYTES 字节)| Gitea/IIS 实测上限 $UPLOAD_LIMIT_BYTES 字节"
if [ "$SIZE_BYTES" -gt "$UPLOAD_LIMIT_BYTES" ]; then
warn "整包超过当前 Gitea 前置 IIS 的上传上限,上传会返回 HTTP 413。"
warn "请让运维放宽上限后重传(做法见 opendance-web/deploy/proxy/README.md):"
warn " IIS requestFiltering/requestLimits@maxAllowedContentLength = 268435456"
warn " ARR maxRequestBytes >= 268435456"
warn " Gitea app.ini [attachment] MAX_SIZE >= 256MB"
warn " nginx(若前置)client_max_body_size 256m;"
warn "本脚本**不会**回退为 .partN 分卷——分卷包无法直接解压,是历史缺陷。"
else
log "低于上限,可直接作为 Release 资产上传。"
fi
echo ""
echo "产物内容:"
unzip -l "$OUT_DIR/$NAME.zip" | sed -n '4,20p'
echo "产物:"
echo " $ZIP"
echo " $OUT_DIR/SHA256SUMS"
if ( cd "$OUT_DIR" && sha256sum -c SHA256SUMS --status ); then
echo " sha256 校验:OK"
else
die "sha256 自校验失败"
fi
echo ""
echo "分发:把 zip 交给 Windows 用户,解压后运行 OpenDance.exe(.pck 必须同目录)。"
unzip -l "$ZIP" | sed -n '3,14p'
echo ""
echo "分发:上传 $NAME.zip 与 SHA256SUMS 两个资产;用户下载后"
echo " sha256sum -c SHA256SUMS && unzip $NAME.zip 即可直接运行 OpenDance.exe。"
+92
View File
@@ -940,4 +940,96 @@ components:
songs: { type: array, items: { $ref: '#/components/schemas/Song' } }
motions: { type: array, items: { $ref: '#/components/schemas/Motion' } }
models: { type: array, items: { $ref: '#/components/schemas/Model' } }
scenes:
type: array
description: 已发布场景(rest-api 0.5.0 起随目录下发;结构见 data-models.md 7)
items: { $ref: '#/components/schemas/Scene' }
defaultModelId: { type: integer, format: int64, nullable: true }
Scene:
allOf:
- $ref: '#/components/schemas/SourceFields'
- type: object
properties:
id: { type: integer, format: int64 }
name: { type: string }
glbUrl: { type: string, nullable: true, description: 场景 GLB 下载地址 }
thumbnailUrl: { type: string, nullable: true }
fileSize: { type: integer, nullable: true }
fileSha256: { type: string, nullable: true }
conversionNote:
type: string
nullable: true
description: 由 .zip/.pmx/.fbx 转换而来时的说明;直接上传 GLB 为 null
config:
allOf: [{ $ref: '#/components/schemas/SceneConfig' }]
nullable: true
description: 物理 / 环境 / 出生点 / 相机;服务端只存参数,仿真在客户端
stats:
type: object
nullable: true
description: 场景 GLB 分析(面数 / 网格 / 材质 / warnings)
status: { $ref: '#/components/schemas/PublishStatus' }
createdAt: { type: string, format: date-time }
updatedAt: { type: string, format: date-time }
SceneConfig:
type: object
description: >-
data-models.md 7.1 冻结结构。服务端入库时对每项做边界收敛
(越界夹紧、非法枚举回退默认值),客户端拿到的参数一定可解释。
properties:
physics:
type: object
properties:
gravity:
type: object
description: 米/秒^2,各分量 ±200
properties:
x: { type: number }
y: { type: number }
z: { type: number }
wind:
type: object
properties:
base: { $ref: '#/components/schemas/Vec3' }
gustAmplitude: { type: number, description: 0~100 }
gustFrequency: { type: number, description: Hz,0.01~5 }
gustDirection: { $ref: '#/components/schemas/Vec3' }
space: { type: string, enum: [world, local] }
ground:
type: object
properties:
friction: { type: number, description: 0~1 }
restitution: { type: number, description: 0~1 }
slopeLimit: { type: number, description: 0~89 度 }
colliders: { type: string, enum: [auto, mesh, none] }
environment:
type: object
properties:
ambientColor: { type: string, description: '#RRGGBB' }
ambientIntensity: { type: number, description: 0~4 }
fogDensity: { type: number, description: 0~1,0=关闭 }
spawnPoints:
type: array
maxItems: 16
items:
type: object
properties:
x: { type: number }
y: { type: number }
z: { type: number }
yaw: { type: number, description: 朝向(度,绕 Y 轴) }
camera:
type: object
properties:
mode: { type: string, enum: [follow, fixed, orbit] }
distance: { type: number, description: 0.5~50 }
height: { type: number, description: 0~20 }
Vec3:
type: object
properties:
x: { type: number }
y: { type: number }
z: { type: number }
+2 -2
View File
@@ -1,6 +1,6 @@
; OpenDance Studio —— Windows 客户端(Godot 4)
;
; 契约版本:见 contracts/VERSION(与 opendance-web 冻结的 v0.1.0 对齐)
; 契约版本:见 contracts/VERSION(rest-api 0.5.0 / data-models 0.7.0 / ...,与 opendance-web 冻结版本对齐)
; 渲染方式选 gl_compatibility:兼容性最好,便于在 Linux CI 上无 GPU 导出。
config_version=5
@@ -9,7 +9,7 @@ config_version=5
config/name="OpenDance Studio"
config/description="摄像头跳舞评分系统 —— Windows 客户端。接收 Android 辅助 App 的 33 点骨骼关键点,驱动 VRM 角色并 DTW 实时评分。"
config/version="0.1.0"
config/version="0.2.0"
run/main_scene="res://scenes/main.tscn"
config/features=PackedStringArray("4.7", "GL Compatibility")
config/icon="res://icon.svg"
+25 -3
View File
@@ -1,9 +1,15 @@
project: opendance-windows-client
version: 0.1.0
version: 0.2.0
repository: opendance-windows
default_branch: main
engine: Godot 4.7.2
contracts_version: 0.1.0
contracts_version:
rest-api: 0.5.0
websocket-keypoints: 0.1.0
data-models: 0.7.0
skeleton-topology: 0.2.0
model-formats: 0.5.0
database: 0.4.0
# 契约变更流程(文档 7.2):
# 任何跨端契约变更必须先在 opendance-web 修改 contracts/ 并提升版本,
@@ -64,13 +70,29 @@ tasks:
file: tasks/011-build-script.md
scope: Windows 打包脚本
status: done
- id: T-WIN-012
file: tasks/012-about-version.md
scope: 「关于」清单展示当前版本号
status: done
- id: T-WIN-013
file: tasks/013-scene-selection.md
scope: API 升级 rest-api 0.5.0 + 场景选择 + 物理落地
status: done
- id: T-WIN-014
file: tasks/014-full-package.md
scope: 发布整包(单个 zip + SHA256SUMS,不拆分卷)
status: done
deliverable:
artifact: opendance-windows-v0.1.0.zip
artifact: opendance-windows-v0.2.0.zip
packaging: Godot 导出脚本(Linux 直接导出 / Windows 兜底)
release_rules: # 文档 §5.7 硬性要求
- 单个完整包,禁止 .partN 分卷
- 同时上传 SHA256SUMS(裸文件名,可直接 sha256sum -c)
includes:
- OpenDance.exe
- OpenDance.pck
- THIRD_PARTY.md
- README.txt
- README.md
- contracts/ # 当前版本契约快照
+81
View File
@@ -23,6 +23,8 @@ var last_error: String = ""
## 参考序列缓存:motion_id → KeypointSequence.ParsedSequence
var _sequence_cache: Dictionary = {}
## 缩略图缓存:url → Texture2D(失败缓存为 null)
var _thumbnail_cache: Dictionary = {}
var _client: ApiClient = null
@@ -62,10 +64,22 @@ func models() -> Array:
return bundle.models if bundle != null else []
func scenes() -> Array:
return bundle.scenes if bundle != null else []
func default_model() -> CatalogTypes.Model:
return bundle.default_model() if bundle != null else null
func default_scene() -> CatalogTypes.Scene:
return bundle.default_scene() if bundle != null else null
func find_scene(scene_id: int) -> CatalogTypes.Scene:
return bundle.find_scene(scene_id) if bundle != null else null
## 拉取目录。成功后 bundle 被替换并发出 catalog_loaded。
func refresh() -> Dictionary:
if is_loading:
@@ -90,6 +104,7 @@ func refresh() -> Dictionary:
bundle = parsed
contract_version = parsed.contract_version
_sequence_cache.clear()
_thumbnail_cache.clear()
catalog_loaded.emit()
return {"ok": true, "data": parsed}
@@ -167,3 +182,69 @@ func scorable_motions_for_song(song_id: int) -> Array:
if motion.is_scorable():
out.append(motion)
return out
# ---------------- 场景(T-WIN-013) ----------------
## 下载场景 GLB 到本地缓存;已存在则直接复用
## 返回 { ok, path, error, cached }
func ensure_scene_cached(scene: CatalogTypes.Scene) -> Dictionary:
if scene == null or not scene.is_usable():
return {"ok": false, "error": "场景缺少可下载的 GLB 地址"}
var path := scene_cache_path(scene)
if FileAccess.file_exists(path):
return {"ok": true, "path": path, "cached": true}
var result: Dictionary = await _client.get_binary(scene.glb_url)
if not result["ok"]:
return {"ok": false, "error": str(result["error"])}
var bytes: PackedByteArray = result["data"]
if bytes.is_empty():
return {"ok": false, "error": "场景文件为空"}
DirAccess.make_dir_recursive_absolute(path.get_base_dir())
var file := FileAccess.open(path, FileAccess.WRITE)
if file == null:
return {"ok": false, "error": "无法写入缓存 %s(%s)" % [path, error_string(FileAccess.get_open_error())]}
file.store_buffer(bytes)
file.close()
return {"ok": true, "path": path, "cached": false, "bytes": bytes.size()}
## 场景缓存路径:user://scenes/<id>-<sha 前 8 位>.glb
## 带指纹是为了服务端换包后不会继续用旧 GLB(模型缓存用的是文件名后缀,场景统一 .glb 必须区分)
static func scene_cache_path(scene: CatalogTypes.Scene) -> String:
var fingerprint := scene.file_sha256.left(8) if scene.file_sha256 != "" else "nohash"
return "user://scenes/%d-%s.glb" % [scene.id, fingerprint]
## 缩略图(服务端离线渲染的 PNG)。失败返回 null 并缓存该结果,避免重复请求。
func fetch_thumbnail(url: String) -> Texture2D:
if url == "":
return null
if _thumbnail_cache.has(url):
return _thumbnail_cache[url]
var result: Dictionary = await _client.get_binary(url)
if not result["ok"]:
_thumbnail_cache[url] = null
return null
var bytes: PackedByteArray = result["data"]
var image := Image.new()
var err := image.load_png_from_buffer(bytes)
if err != OK:
err = image.load_jpg_from_buffer(bytes)
if err != OK:
_thumbnail_cache[url] = null
return null
var texture := ImageTexture.create_from_image(image)
_thumbnail_cache[url] = texture
return texture
func clear_thumbnail_cache() -> void:
_thumbnail_cache.clear()
+286
View File
@@ -160,11 +160,256 @@ class Model extends RefCounted:
return format == "vrm"
## ---------------- 场景(契约 data-models §7,T-WIN-013) ----------------
##
## 场景与模型不同:**以 GLB 为主**(不做 humanoid 重绑定、不转 VRM),
## 并携带**物理参数**;服务端只存参数、仿真在本端(Godot 内置物理)。
## 本文件的 from_dict 会按契约 7.1 的边界再收敛一次 —— 服务端已收敛,
## 但客户端不能假设对端一定合法(旧版本服务端 / 人工改包)。
## 风:基础风 + 阵风(正弦叠加)
class Wind extends RefCounted:
var base: Vector3 = Vector3.ZERO
var gust_amplitude: float = 0.0
var gust_frequency: float = 0.2
var gust_direction: Vector3 = Vector3(1, 0, 0)
## world | local
var space: String = "world"
static func from_dict(d: Dictionary) -> Wind:
var out := Wind.new()
out.base = CatalogTypes._vec3(d.get("base"), Vector3.ZERO, 100.0)
out.gust_amplitude = CatalogTypes._num(d.get("gustAmplitude"), 0.0, 0.0, 100.0)
out.gust_frequency = CatalogTypes._num(d.get("gustFrequency"), 0.2, 0.01, 5.0)
out.gust_direction = CatalogTypes._vec3(d.get("gustDirection"), Vector3(1, 0, 0), 1.0)
out.space = "local" if str(d.get("space", "world")) == "local" else "world"
return out
## 是否完全无风(可用于跳过风场施加)
func is_calm() -> bool:
return base.is_zero_approx() and is_zero_approx(gust_amplitude)
## 某一时刻的风向量:基础风 + 阵风(正弦)
func sample(time_seconds: float) -> Vector3:
if is_calm():
return Vector3.ZERO
if is_zero_approx(gust_amplitude):
return base
var dir := gust_direction
if dir.is_zero_approx():
dir = Vector3(1, 0, 0)
var wave := sin(TAU * gust_frequency * time_seconds) * gust_amplitude
return base + dir.normalized() * wave
func summary() -> String:
if is_calm():
return "无风"
var text := "基础风 %.1f,%.1f,%.1f m/s" % [base.x, base.y, base.z]
if not is_zero_approx(gust_amplitude):
text += " | 阵风 %.1f m/s @ %.2f Hz(%s)" % [
gust_amplitude, gust_frequency, "局部坐标" if space == "local" else "世界坐标",
]
return text
class Ground extends RefCounted:
var friction: float = 0.6
var restitution: float = 0.0
## 可行走坡度上限(度)
var slope_limit: float = 45.0
static func from_dict(d: Dictionary) -> Ground:
var out := Ground.new()
out.friction = CatalogTypes._num(d.get("friction"), 0.6, 0.0, 1.0)
out.restitution = CatalogTypes._num(d.get("restitution"), 0.0, 0.0, 1.0)
out.slope_limit = CatalogTypes._num(d.get("slopeLimit"), 45.0, 0.0, 89.0)
return out
## 转成 Godot 物理材质(摩擦 / 弹性)
func to_physics_material() -> PhysicsMaterial:
var material := PhysicsMaterial.new()
material.friction = friction
material.bounce = restitution
return material
class ScenePhysics extends RefCounted:
## 重力向量(米/秒^2,默认 (0,-9.8,0))
var gravity: Vector3 = Vector3(0, -9.8, 0)
var wind: Wind = Wind.new()
var ground: Ground = Ground.new()
## auto | mesh | none
var colliders: String = "auto"
static func from_dict(d: Dictionary) -> ScenePhysics:
var out := ScenePhysics.new()
out.gravity = CatalogTypes._vec3(d.get("gravity"), Vector3(0, -9.8, 0), 200.0)
out.wind = Wind.from_dict(CatalogTypes._dict(d.get("wind")))
out.ground = Ground.from_dict(CatalogTypes._dict(d.get("ground")))
var colliders := str(d.get("colliders", "auto"))
out.colliders = colliders if colliders in ["mesh", "none"] else "auto"
return out
func gravity_magnitude() -> float:
return gravity.length()
class SceneEnvironment extends RefCounted:
var ambient_color: Color = Color(0.561, 0.706, 1.0)
var ambient_intensity: float = 1.0
## 0 = 关闭
var fog_density: float = 0.0
static func from_dict(d: Dictionary) -> SceneEnvironment:
var out := SceneEnvironment.new()
var raw := str(d.get("ambientColor", ""))
if raw.length() == 7 and raw.begins_with("#"):
out.ambient_color = Color.html(raw)
out.ambient_intensity = CatalogTypes._num(d.get("ambientIntensity"), 1.0, 0.0, 4.0)
out.fog_density = CatalogTypes._num(d.get("fogDensity"), 0.0, 0.0, 1.0)
return out
class SpawnPoint extends RefCounted:
var position: Vector3 = Vector3.ZERO
## 朝向(度,绕 Y 轴)
var yaw: float = 0.0
static func from_dict(d: Dictionary) -> SpawnPoint:
var out := SpawnPoint.new()
out.position = Vector3(
CatalogTypes._num(d.get("x"), 0.0, -1000.0, 1000.0),
CatalogTypes._num(d.get("y"), 0.0, -1000.0, 1000.0),
CatalogTypes._num(d.get("z"), 0.0, -1000.0, 1000.0),
)
out.yaw = CatalogTypes._num(d.get("yaw"), 0.0, -360.0, 360.0)
return out
class SceneCamera extends RefCounted:
## follow | fixed | orbit
var mode: String = "follow"
var distance: float = 4.5
var height: float = 1.6
static func from_dict(d: Dictionary) -> SceneCamera:
var out := SceneCamera.new()
var mode := str(d.get("mode", "follow"))
out.mode = mode if mode in ["fixed", "orbit"] else "follow"
out.distance = CatalogTypes._num(d.get("distance"), 4.5, 0.5, 50.0)
out.height = CatalogTypes._num(d.get("height"), 1.6, 0.0, 20.0)
return out
func label() -> String:
match mode:
"fixed": return "固定机位"
"orbit": return "环绕"
return "跟随"
## 契约 data-models §7.1(冻结)
class SceneConfig extends RefCounted:
var physics: ScenePhysics = ScenePhysics.new()
var environment: SceneEnvironment = SceneEnvironment.new()
var spawn_points: Array = []
var camera: SceneCamera = SceneCamera.new()
## 服务端默认值(与 web-server/src/scenes/scene-config.ts 的 SCENE_CONFIG_DEFAULTS 对齐)
static func defaults() -> SceneConfig:
var out := SceneConfig.new()
out.physics = ScenePhysics.new()
out.environment = SceneEnvironment.new()
out.spawn_points = [SpawnPoint.new()]
out.camera = SceneCamera.new()
return out
## 把任意输入收敛成合法配置:缺项用默认值、越界夹紧、非法枚举回退。
## **永不抛错** —— 与服务端 normalizeSceneConfig 同规则。
static func from_dict(d: Dictionary) -> SceneConfig:
var fb := SceneConfig.defaults()
var out := SceneConfig.new()
out.physics = ScenePhysics.from_dict(CatalogTypes._dict(d.get("physics")))
out.environment = SceneEnvironment.from_dict(CatalogTypes._dict(d.get("environment")))
out.camera = SceneCamera.from_dict(CatalogTypes._dict(d.get("camera")))
var raw_spawn: Variant = d.get("spawnPoints")
var points: Array = []
if typeof(raw_spawn) == TYPE_ARRAY and (raw_spawn as Array).size() > 0:
for item in raw_spawn:
points.append(SpawnPoint.from_dict(CatalogTypes._dict(item)))
points = points.slice(0, 16)
else:
points = fb.spawn_points
out.spawn_points = points
return out
func first_spawn() -> SpawnPoint:
if spawn_points.is_empty():
return SpawnPoint.new()
return spawn_points[0]
## 界面用的一行摘要(重力 / 风 / 摩擦)
func summary() -> String:
return "重力 %.1f m/s² | 摩擦 %.2f | 弹性 %.2f | %s | %s" % [
physics.gravity_magnitude(), physics.ground.friction, physics.ground.restitution,
physics.colliders, physics.wind.summary(),
]
## 契约 data-models §7:场景(GLB 为主)
class Scene extends RefCounted:
var id: int = 0
var name: String = ""
var glb_url: String = ""
var thumbnail_url: String = ""
var file_size: int = 0
var file_sha256: String = ""
var conversion_note: String = ""
var config: SceneConfig = SceneConfig.new()
var stats: Dictionary = {}
var source: SourceInfo = SourceInfo.new()
var status: String = ""
static func from_dict(d: Dictionary) -> Scene:
var out := Scene.new()
out.id = int(d.get("id", 0))
out.name = str(d.get("name", ""))
out.glb_url = CatalogTypes._nullable_string(d.get("glbUrl"))
out.thumbnail_url = CatalogTypes._nullable_string(d.get("thumbnailUrl"))
out.file_size = int(d.get("fileSize", 0) if d.get("fileSize") != null else 0)
out.file_sha256 = CatalogTypes._nullable_string(d.get("fileSha256"))
out.conversion_note = CatalogTypes._nullable_string(d.get("conversionNote"))
var raw_config: Variant = d.get("config")
if typeof(raw_config) == TYPE_DICTIONARY:
out.config = SceneConfig.from_dict(raw_config)
else:
# 服务端可能返回 null(理论上入库即收敛,这里只做防御)
out.config = SceneConfig.defaults()
var raw_stats: Variant = d.get("stats")
out.stats = raw_stats if typeof(raw_stats) == TYPE_DICTIONARY else {}
out.source = SourceInfo.from_dict(d)
out.status = str(d.get("status", ""))
return out
## 是否具备可实例化的条件(缺 GLB 时界面提示并回退内置空场景)
func is_usable() -> bool:
return glb_url != ""
func size_label() -> String:
if file_size <= 0:
return "—"
if file_size < 1024 * 1024:
return "%.0f KB" % (file_size / 1024.0)
return "%.1f MB" % (file_size / 1048576.0)
## 契约 §6:客户端一次性拉取的目录
class CatalogBundle extends RefCounted:
var songs: Array = []
var motions: Array = []
var models: Array = []
## 已发布场景(rest-api 0.5.0 起随目录下发)
var scenes: Array = []
var default_model_id: int = -1
var contract_version: Dictionary = {}
var generated_at: String = ""
@@ -177,6 +422,8 @@ class CatalogBundle extends RefCounted:
out.motions.append(Motion.from_dict(item))
for item in d.get("models", []):
out.models.append(Model.from_dict(item))
for item in d.get("scenes", []):
out.scenes.append(Scene.from_dict(item))
var default_id: Variant = d.get("defaultModelId")
out.default_model_id = int(default_id) if default_id != null else -1
out.contract_version = d.get("contractVersion", {})
@@ -201,6 +448,20 @@ class CatalogBundle extends RefCounted:
return model
return null
func find_scene(id: int) -> Scene:
for scene in scenes:
if scene.id == id:
return scene
return null
## 场景无「服务端推荐默认」字段(契约 7 未定义 defaultSceneId),
## 因此按列表顺序取第一个具备 GLB 的场景;都没有则返回 null(回退内置空场景)。
func default_scene() -> Scene:
for scene in scenes:
if scene.is_usable():
return scene
return null
func motions_for_song(song_id: int) -> Array:
var out: Array = []
for motion in motions:
@@ -233,3 +494,28 @@ static func _nullable_string(value: Variant) -> String:
if value == null:
return ""
return str(value)
## 空字典兜底(JSON 的 null / 缺失 / 非字典一律当空字典)
static func _dict(value: Variant) -> Dictionary:
return value if typeof(value) == TYPE_DICTIONARY else {}
## 数值收敛:非数值用默认值,越界夹紧(与服务端 num() 同规则)
static func _num(value: Variant, fallback: float, min_value: float, max_value: float) -> float:
var parsed := fallback
if typeof(value) == TYPE_FLOAT or typeof(value) == TYPE_INT:
var as_float := float(value)
if is_finite(as_float):
parsed = as_float
return clampf(parsed, min_value, max_value)
## 向量收敛:各分量 ±limit
static func _vec3(value: Variant, fallback: Vector3, limit: float) -> Vector3:
var raw := _dict(value)
return Vector3(
_num(raw.get("x"), fallback.x, -limit, limit),
_num(raw.get("y"), fallback.y, -limit, limit),
_num(raw.get("z"), fallback.z, -limit, limit),
)
+380
View File
@@ -0,0 +1,380 @@
class_name SceneRuntime
extends Node3D
## T-WIN-013:场景实例化与物理落地
##
## 职责边界:**服务端只存参数、不跑物理,仿真在本端**(见契约 data-models §7)。
## 本类把服务端下发的 `SceneConfig` 落地为 Godot 物理:
## 1. 载入 GLB 场景(`GLTFDocument`,与 VrmLoader 同一条零原生依赖路线);
## 2. 按 `colliders` 生成 `StaticBody3D` 碰撞体并套用 `ground` 物理材质;
## 3. 用 `Area3D`(SPACE_OVERRIDE_REPLACE)实现**场景级重力**,不污染全局 ProjectSettings;
## 4. 用 `Area3D` + 每物理帧 `apply_central_force` 施加风力,阵风按正弦叠加;
## 5. 环境光 / 雾与相机机位。
##
## 容错(文档 §5.6 第 4 条):场景缺失、GLB 解析失败、参数非法一律**不抛错、不阻塞开始跟练**,
## 改为记录 `warnings` 并回退到内置空场景 + 兜底地面。
const CatalogTypes = preload("res://src/net/catalog_types.gd")
signal wind_force_applied(force: Vector3)
## 风向量(米/秒)→ 力(牛顿)的换算系数。
## 契约只约束风的量纲(m/s),未约束受力模型;本端统一取该系数,
## 使 1 m/s 的风对 1 kg 刚体产生 10 N 的力(可调,便于目视与日志核对)。
const WIND_FORCE_SCALE := 10.0
## `colliders: auto` 时识别为碰撞体的节点名后缀(对齐 Godot/Blender 命名约定)
const AUTO_COLLIDER_SUFFIXES := ["_col", "-col", "_colonly", "-colonly", "_collision", "-collision", "_collider", "-collider"]
## 服务端 fogDensity 0~1 → Godot fog_density 的映射区间。
## Godot 的 fog_density 量纲更敏感(默认 0.01),直接用 0~1 会全屏糊死。
const FOG_DENSITY_MIN := 0.005
const FOG_DENSITY_MAX := 0.05
var config: CatalogTypes.SceneConfig = null
var scene_name: String = ""
## 生成出来的场景根节点(本节点自身是运行时容器)
var root: Node3D = null
var warnings: PackedStringArray = PackedStringArray()
var collider_count: int = 0
var used_fallback_ground: bool = false
var source_path: String = ""
var _material: PhysicsMaterial = null
var _gravity_area: Area3D = null
var _wind_area: Area3D = null
var _wind_time: float = 0.0
var _bounds: AABB = AABB()
var _last_wind_force: Vector3 = Vector3.ZERO
var _bodies_hit: int = 0
## 载入场景 GLB 并落地物理;任何失败都回退到内置空场景(不抛错)。
static func load_from_file(path: String, scene_config: CatalogTypes.SceneConfig, display_name: String) -> SceneRuntime:
var runtime := SceneRuntime.new()
runtime.scene_name = display_name
runtime.source_path = path
runtime.config = scene_config if scene_config != null else CatalogTypes.SceneConfig.defaults()
var scene_root: Node3D = null
if path == "" or not FileAccess.file_exists(path):
runtime.warnings.append("场景文件不存在:%s" % path)
else:
var document := GLTFDocument.new()
var state := GLTFState.new()
var err := document.append_from_file(path, state)
if err != OK:
runtime.warnings.append("场景 GLB 解析失败:%s" % error_string(err))
else:
var generated: Node = document.generate_scene(state)
if generated == null:
runtime.warnings.append("无法从场景 GLB 生成节点树")
elif not (generated is Node3D):
runtime.warnings.append("场景 GLB 根节点不是 Node3D")
generated.free()
else:
scene_root = generated as Node3D
if scene_root == null:
runtime.warnings.append("已回退到内置空场景(不影响开始跟练)")
runtime._assemble(scene_root)
return runtime
## 构造纯参数化的运行时(无 GLB,仅兜底地面)——服务端无可用场景时使用
static func create_empty(scene_config: CatalogTypes.SceneConfig, display_name: String = "内置空场景") -> SceneRuntime:
var runtime := SceneRuntime.new()
runtime.scene_name = display_name
runtime.config = scene_config if scene_config != null else CatalogTypes.SceneConfig.defaults()
runtime.warnings.append("使用内置空场景(服务端未发布可用场景或加载失败)")
runtime._assemble(null)
return runtime
func _assemble(scene_root: Node3D) -> void:
name = "SceneRuntime"
_material = config.physics.ground.to_physics_material()
if scene_root != null:
root = scene_root
add_child(root)
_bounds = _compute_aabb(self)
_build_colliders()
_build_gravity_area()
_build_wind_area()
set_physics_process(true)
# ---------------- 重力 ----------------
func _build_gravity_area() -> void:
var area := Area3D.new()
area.name = "SceneGravityArea"
# REPLACE:区域内的刚体完全改用场景重力,不动 ProjectSettings 的全局重力
area.gravity_space_override = Area3D.SPACE_OVERRIDE_REPLACE
area.gravity_point = false
var g: Vector3 = config.physics.gravity
area.gravity_direction = Vector3.DOWN if g.is_zero_approx() else g.normalized()
area.gravity = config.physics.gravity_magnitude()
area.collision_layer = 1
area.collision_mask = 1
area.add_child(_make_box_collision(_area_extents()))
add_child(area)
_gravity_area = area
if g.is_zero_approx():
warnings.append("场景重力为 0(失重)")
# ---------------- 风 ----------------
func _build_wind_area() -> void:
var area := Area3D.new()
area.name = "SceneWindArea"
area.gravity_space_override = Area3D.SPACE_OVERRIDE_DISABLED
area.collision_layer = 1
area.collision_mask = 1
area.monitoring = true
area.add_child(_make_box_collision(_area_extents()))
add_child(area)
_wind_area = area
func _physics_process(delta: float) -> void:
if config == null or _wind_area == null:
return
if config.physics.wind.is_calm():
_last_wind_force = Vector3.ZERO
_bodies_hit = 0
return
_wind_time += delta
var force := wind_force_at(_wind_time)
_last_wind_force = force
var affected := 0
for body in _wind_area.get_overlapping_bodies():
if body is RigidBody3D:
var rigid := body as RigidBody3D
rigid.apply_central_force(force)
affected += 1
_bodies_hit = affected
if affected > 0:
wind_force_applied.emit(force)
## 某个时刻的风力(牛顿)——**纯函数,可无头单测**
func wind_force_at(time_seconds: float) -> Vector3:
if config == null:
return Vector3.ZERO
var v: Vector3 = config.physics.wind.sample(time_seconds)
if config.physics.wind.space == "local":
v = global_transform.basis * v
return v * WIND_FORCE_SCALE
## 某个刚体受到的重力(牛顿)——诊断/单测用;实际重力由 SceneGravityArea 施加
func gravity_force_for(body: RigidBody3D) -> Vector3:
if config == null or body == null:
return Vector3.ZERO
return config.physics.gravity * body.mass
func gravity_magnitude() -> float:
return config.physics.gravity_magnitude() if config != null else 0.0
# ---------------- 碰撞体 ----------------
func _build_colliders() -> void:
var mode := config.physics.colliders
var meshes := _collect_meshes(self)
if mode == "mesh":
for mesh in meshes:
mesh.create_trimesh_collision()
collider_count += 1
elif mode == "auto":
for mesh in meshes:
var lower := String(mesh.name).to_lower()
var matched := ""
for suffix in AUTO_COLLIDER_SUFFIXES:
if lower.ends_with(suffix):
matched = suffix
break
if matched == "":
continue
mesh.create_trimesh_collision()
collider_count += 1
# `-colonly` 约定:该网格只提供碰撞,不参与渲染
if matched.ends_with("colonly"):
mesh.visible = false
else:
# colliders: none —— 尊重场景作者意图,不生成任何碰撞体,也不兜底
warnings.append("场景配置 colliders=none,本次不生成任何碰撞体")
_apply_material_to_static_bodies()
# auto / mesh 下一个碰撞体都没找到时兜底,避免角色无限下落
if collider_count == 0 and mode != "none":
_add_fallback_ground()
used_fallback_ground = true
warnings.append("未从场景中找到碰撞体(colliders=%s),已生成兜底地面" % mode)
func _apply_material_to_static_bodies() -> void:
for node in _collect_static_bodies(self):
if node.physics_material_override == null:
node.physics_material_override = _material
func _add_fallback_ground() -> void:
var body := StaticBody3D.new()
body.name = "FallbackGround"
var shape := CollisionShape3D.new()
var box := BoxShape3D.new()
# 以场景包围盒的水平尺寸(至少 40×40 米)铺一块薄板,顶面落在场景最低点
var width := maxf(_bounds.size.x, 40.0)
var depth := maxf(_bounds.size.z, 40.0)
var top := _bounds.position.y if _bounds.size != Vector3.ZERO else 0.0
box.size = Vector3(width, 0.2, depth)
shape.shape = box
body.add_child(shape)
body.position = Vector3(_bounds.get_center().x, top - 0.1, _bounds.get_center().z)
body.physics_material_override = _material
add_child(body)
collider_count += 1
# ---------------- 环境 ----------------
## 把场景的环境参数写进调用方已有的 Environment(不新建 WorldEnvironment,避免互相覆盖)
func apply_environment(env: Environment) -> void:
if env == null or config == null:
return
env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
env.ambient_light_color = config.environment.ambient_color
env.ambient_light_energy = config.environment.ambient_intensity
if config.environment.fog_density > 0.0:
env.fog_enabled = true
env.fog_light_color = config.environment.ambient_color
env.fog_density = _godot_fog_density()
else:
env.fog_enabled = false
func _godot_fog_density() -> float:
var d := clampf(config.environment.fog_density, 0.0, 1.0)
return clampf(d * (FOG_DENSITY_MAX - FOG_DENSITY_MIN) + FOG_DENSITY_MIN, FOG_DENSITY_MIN, FOG_DENSITY_MAX)
# ---------------- 出生点 / 相机 ----------------
## 场景第 index 个出生点的位姿(yaw 为度,绕 Y 轴);越界回退第一个
func spawn_transform(index: int = 0) -> Transform3D:
var points: Array = config.spawn_points
if points.is_empty():
return Transform3D.IDENTITY
var point: CatalogTypes.SpawnPoint = points[index] if index >= 0 and index < points.size() else points[0]
var basis := Basis(Vector3.UP, deg_to_rad(point.yaw))
return Transform3D(basis, point.position)
## 按场景相机配置摆机位;fixed 用出生点、follow 跟目标、orbit 绕目标缓慢环绕
func apply_camera(camera: Camera3D, target: Vector3, time_seconds: float = 0.0) -> void:
if camera == null or config == null:
return
var cfg: CatalogTypes.SceneCamera = config.camera
var focus := target
match cfg.mode:
"fixed":
focus = spawn_transform().origin
"orbit":
var angle := time_seconds * 0.35
var orbit_pos := focus + Vector3(sin(angle), 0.0, cos(angle)) * cfg.distance + Vector3(0, cfg.height, 0)
# look_at_from_position 不要求节点已在树内,便于无头单测逐值核对机位
camera.look_at_from_position(orbit_pos, focus + Vector3(0, cfg.height * 0.2, 0), Vector3.UP)
return
var position := focus + Vector3(0, cfg.height, cfg.distance)
camera.look_at_from_position(position, focus + Vector3(0, cfg.height * 0.2, 0), Vector3.UP)
# ---------------- 诊断 ----------------
## 一行摘要(日志 / 界面用),保证"目视 + 日志核对"可落地
func describe() -> String:
if config == null:
return "场景未配置"
return "场景「%s」:%s | 碰撞体 %d 个%s | 相机 %s" % [
scene_name if scene_name != "" else "未命名",
config.summary(),
collider_count,
"(含兜底地面)" if used_fallback_ground else "",
config.camera.label(),
]
func warnings_text() -> String:
return ";".join(warnings) if warnings.size() > 0 else ""
func last_wind_force() -> Vector3:
return _last_wind_force
func affected_body_count() -> int:
return _bodies_hit
# ---------------- 工具 ----------------
func _area_extents() -> Vector3:
var size := _bounds.size
if size == Vector3.ZERO:
return Vector3(200, 200, 200)
# 水平各留 2 倍余量、向上多留 50 米(角色可能被抛起),向下留到地面以下
return Vector3(size.x * 2.0 + 20.0, size.y + 60.0, size.z * 2.0 + 20.0)
func _make_box_collision(size: Vector3) -> CollisionShape3D:
var shape := CollisionShape3D.new()
var box := BoxShape3D.new()
box.size = size
shape.shape = box
shape.position = _bounds.get_center() + Vector3(0, 20.0, 0)
return shape
static func _collect_meshes(node: Node) -> Array:
var out: Array = []
if node is MeshInstance3D:
out.append(node)
for child in node.get_children():
out.append_array(_collect_meshes(child))
return out
static func _collect_static_bodies(node: Node) -> Array:
var out: Array = []
if node is StaticBody3D:
out.append(node)
for child in node.get_children():
out.append_array(_collect_static_bodies(child))
return out
static func _compute_aabb(node: Node) -> AABB:
var result := AABB()
var first := true
if node is MeshInstance3D:
var mesh_node := node as MeshInstance3D
if mesh_node.visible:
result = mesh_node.global_transform * mesh_node.get_aabb()
first = false
for child in node.get_children():
var child_aabb := _compute_aabb(child)
if child_aabb.size == Vector3.ZERO:
continue
if first:
result = child_aabb
first = false
else:
result = result.merge(child_aabb)
return result
+1
View File
@@ -0,0 +1 @@
uid://dnpayvixv5cgc
+62 -5
View File
@@ -2,7 +2,13 @@ class_name AboutScreen
extends Control
## T-WIN-010:关于页面 + 开源声明(可滚动)
## T-WIN-012:关于清单展示当前版本号(文档 §11.1:Windows 端在「关于」清单)
##
## 文档 §8.3 要求 Windows 客户端与 Android App 的「关于」页展示同款声明。
##
## 版本号**唯一来源**是 project.godot 的 application/config/version
## (与 project.yaml 的 version、导出产物名 opendance-windows-vX.Y.Z.zip、Git tag 一致),
## 本文件不再手写一份(文档 §11.2 第 1 条硬性要求)。
const UiKit = preload("res://src/ui/ui_kit.gd")
const Credits = preload("res://src/ui/credits.gd")
@@ -14,6 +20,49 @@ func _init(on_back: Callable = Callable()) -> void:
_on_back = on_back
## 当前运行版本(构建期由 project.godot 注入,运行期读取)
static func app_version() -> String:
return str(ProjectSettings.get_setting("application/config/version", "0.0.0"))
## 取 AppConfig 自动加载实例。测试在 `--script` 模式下没有 autoload,
## 因此这里必须容错返回 null,而不是直接引用 AppConfig 造成解析期错误。
static func _app_config() -> Node:
var loop := Engine.get_main_loop()
if loop is SceneTree:
return (loop as SceneTree).root.get_node_or_null("AppConfig")
return null
static func _config_call(method: String, fallback: String) -> String:
var cfg := _app_config()
if cfg != null and cfg.has_method(method):
return str(cfg.call(method))
return fallback
static func _config_property(name: String, fallback: String) -> String:
var cfg := _app_config()
if cfg != null:
var value: Variant = cfg.get(name)
if value != null:
return str(value)
return fallback
## 「关于」清单的一行行内容:版本 / 引擎 / 契约 / 运行地址 / 隐私
static func about_manifest() -> Array:
return [
{"label": "客户端版本", "value": "v%s" % app_version()},
{"label": "运行引擎", "value": "Godot %s" % str(Engine.get_version_info().get("string", "?"))},
{"label": "契约版本", "value": Credits.summary()},
{"label": "接收协议", "value": "websocket-keypoints v%s(33 点 / 239 字节帧)" % Credits.version_of("websocket-keypoints")},
{"label": "接收地址", "value": _config_call("listen_url", "ws://0.0.0.0:8765/pose")},
{"label": "服务端地址", "value": _config_property("api_base_url", "http://127.0.0.1:3000")},
{"label": "隐私说明", "value": "仅接收关键点,不接收、不存储任何视频画面"},
]
func _ready() -> void:
set_anchors_preset(Control.PRESET_FULL_RECT)
@@ -33,12 +82,20 @@ func _ready() -> void:
root.add_child(header)
var info := UiKit.panel()
var info_box := UiKit.vbox(2)
info_box.add_child(UiKit.label("Windows 客户端 v%s" % ProjectSettings.get_setting("application/config/version", "0.1.0"), UiKit.FONT_HEADING))
info_box.add_child(UiKit.muted("摄像头跳舞评分系统:接收 Android 辅助 App 通过 WebSocket 推送的 33 点骨骼关键点,驱动 VRM 角色并实时 DTW 评分。"))
var info_box := UiKit.vbox(4)
info_box.add_child(UiKit.heading("清单"))
for item in about_manifest():
var row := UiKit.hbox(8)
var key := UiKit.label("%s:" % str(item["label"]), UiKit.FONT_SMALL, UiKit.COLOR_MUTED)
key.custom_minimum_size = Vector2(96, 0)
row.add_child(key)
var value := UiKit.label(str(item["value"]), UiKit.FONT_SMALL)
value.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
value.size_flags_horizontal = Control.SIZE_EXPAND_FILL
row.add_child(value)
info_box.add_child(row)
info_box.add_child(UiKit.spacer(4))
info_box.add_child(UiKit.muted("契约版本:rest-api / data-models / skeleton-topology / websocket-keypoints / model-formats = v%s" % Credits.CONTRACT_VERSION))
info_box.add_child(UiKit.muted("本客户端仅接收关键点,不接收、不存储任何视频画面。"))
info_box.add_child(UiKit.muted("摄像头跳舞评分系统:接收 Android 辅助 App 通过 WebSocket 推送的 33 点骨骼关键点,驱动 VRM 角色并实时 DTW 评分。"))
info.add_child(info_box)
root.add_child(info)
+24 -3
View File
@@ -27,8 +27,28 @@ const ENTRIES := [
{"name": "mysql2", "license": "MIT", "url": "https://github.com/sidorares/node-mysql2", "copyright": "", "usage": "Web 服务端数据库驱动"},
]
## 契约版本(与 contracts/VERSION 对齐)
const CONTRACT_VERSION := "0.1.0"
## 契约版本(唯一来源:contracts/VERSION 快照)
## 六项各自独立递增(rest-api 已到 0.5.0 时 skeleton-topology 仍可能是 0.2.0),
## 因此不能用单个常量表示;界面展示统一走 summary()。
const CONTRACT_VERSIONS := {
"rest-api": "0.5.0",
"websocket-keypoints": "0.1.0",
"data-models": "0.7.0",
"skeleton-topology": "0.2.0",
"model-formats": "0.5.0",
"database": "0.4.0",
}
## 契约版本是「字典 + 求值」而非单个常量,避免新增契约时忘记同步展示
static func version_of(name: String) -> String:
return str(CONTRACT_VERSIONS.get(name, "?"))
static func summary() -> String:
var parts := PackedStringArray()
for key in CONTRACT_VERSIONS:
parts.append("%s v%s" % [key, CONTRACT_VERSIONS[key]])
return " | ".join(parts)
## 渲染为 BBCode(用于 RichTextLabel)
@@ -36,7 +56,8 @@ static func to_bbcode() -> String:
var lines := PackedStringArray()
lines.append("[b][font_size=20]第三方开源项目声明[/font_size][/b]")
lines.append("")
lines.append("本软件使用了以下开源项目,特此声明。契约版本 v%s。" % CONTRACT_VERSION)
lines.append("本软件使用了以下开源项目,特此声明。")
lines.append("契约版本:%s。" % summary())
lines.append("")
for i in ENTRIES.size():
var entry: Dictionary = ENTRIES[i]
+139 -6
View File
@@ -3,12 +3,15 @@ extends Control
## T-WIN-008:游戏界面(3D 角色 + 实时评分 HUD)
## T-WIN-009:来源信息展示
## T-WIN-013:场景载入与物理落地(重力 / 风 / 碰撞 / 环境 / 机位)
const UiKit = preload("res://src/ui/ui_kit.gd")
const Scoring = preload("res://src/score/scoring.gd")
const VrmLoader = preload("res://src/model/vrm_loader.gd")
const SkeletonRetarget = preload("res://src/anim/skeleton_retarget.gd")
const SourcePanel = preload("res://src/ui/source_panel.gd")
const ScenePanel = preload("res://src/ui/scene_panel.gd")
const SceneRuntime = preload("res://src/scene/scene_runtime.gd")
const CatalogTypes = preload("res://src/net/catalog_types.gd")
enum State { IDLE, PREPARING, RUNNING, FINISHED, FAILED }
@@ -16,10 +19,14 @@ enum State { IDLE, PREPARING, RUNNING, FINISHED, FAILED }
var state: State = State.IDLE
var vrm: VrmLoader.VrmModel = null
var retarget: SkeletonRetarget = null
var scene_runtime: SceneRuntime = null
var scene_warning: String = ""
var _viewport: SubViewport
var _camera: Camera3D
var _world_env: Environment
var _model_host: Node3D
var _scene_host: Node3D
var _score_label: Label
var _grade_label: Label
var _combo_label: Label
@@ -27,14 +34,18 @@ var _progress: ProgressBar
var _status_label: Label
var _hint_label: Label
var _source_panel: SourcePanel
var _scene_panel: ScenePanel
var _scene_note: Label
var _start_button: Button
var _stop_button: Button
var _motion: CatalogTypes.Motion = null
var _song: CatalogTypes.Song = null
var _model: CatalogTypes.Model = null
var _scene: CatalogTypes.Scene = null
var _good_frames: int = 0
var _total_frames: int = 0
var _elapsed: float = 0.0
var _on_exit: Callable = Callable()
var _on_finished: Callable = Callable()
@@ -74,7 +85,7 @@ func _ready() -> void:
# ---- 右侧:HUD ----
var side := UiKit.vbox(10)
side.custom_minimum_size = Vector2(340, 0)
side.custom_minimum_size = Vector2(380, 0)
root.add_child(side)
side.add_child(UiKit.title("评分"))
@@ -108,6 +119,14 @@ func _ready() -> void:
_source_panel = SourcePanel.new("动作来源")
side.add_child(_source_panel)
_scene_panel = ScenePanel.new("场景参数")
side.add_child(_scene_panel)
_scene_note = UiKit.label("", UiKit.FONT_SMALL, UiKit.COLOR_MUTED)
_scene_note.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_scene_note.visible = false
side.add_child(_scene_note)
side.add_child(UiKit.expander())
var actions := UiKit.hbox(8)
@@ -137,8 +156,11 @@ func _env_setup() -> void:
env.ambient_light_energy = 0.9
world.environment = env
_viewport.add_child(world)
# 场景的环境参数(环境光 / 雾)直接写进这个 Environment,不另建 WorldEnvironment
_world_env = env
var light := DirectionalLight3D.new()
light.name = "SceneSun"
light.rotation_degrees = Vector3(-35, 40, 0)
light.light_energy = 1.2
_viewport.add_child(light)
@@ -149,17 +171,25 @@ func _env_setup() -> void:
_camera.fov = 40.0
_viewport.add_child(_camera)
_scene_host = Node3D.new()
_scene_host.name = "SceneHost"
_viewport.add_child(_scene_host)
_model_host = Node3D.new()
_model_host.name = "ModelHost"
_viewport.add_child(_model_host)
## 设置本轮要跟练的曲目与动作,并加载模型
func prepare(song: CatalogTypes.Song, motion: CatalogTypes.Motion, model: CatalogTypes.Model) -> void:
## 设置本轮要跟练的场景、曲目与动作,并加载模型
## `scene` 可以为 null(服务端未发布场景)—— 此时用内置空场景,**不阻塞开始跟练**
func prepare(song: CatalogTypes.Song, motion: CatalogTypes.Motion, model: CatalogTypes.Model, scene: CatalogTypes.Scene = null) -> void:
_song = song
_motion = motion
_model = model
_scene = scene
_good_frames = 0
_total_frames = 0
_elapsed = 0.0
state = State.PREPARING
_progress.value = 0.0
_score_label.text = "—"
@@ -168,14 +198,21 @@ func prepare(song: CatalogTypes.Song, motion: CatalogTypes.Motion, model: Catalo
_stop_button.disabled = true
_source_panel.show_source(motion.source if motion != null else null, motion.name if motion != null else "")
_set_status("正在下载并加载模型…")
_scene_panel.show_scene(scene)
_set_status("正在载入场景…")
_clear_model()
_clear_scene()
var scene_note: String = await _load_scene(scene)
if scene_note != "":
scene_warning = scene_note
if model == null or model.file_url == "":
_fail("目录中没有可用模型(未发布任何模型)")
return
_set_status("正在下载并加载模型…")
var cached: Dictionary = await CatalogStore.ensure_model_cached(model)
if not cached["ok"]:
_fail("模型下载失败:%s" % str(cached["error"]))
@@ -189,7 +226,10 @@ func prepare(song: CatalogTypes.Song, motion: CatalogTypes.Motion, model: Catalo
vrm.root.visible = true
_model_host.add_child(vrm.root)
_frame_camera_on(vrm.root)
# 角色落在场景出生点(无场景时即世界原点),再按相机模式取景
_place_character_on_spawn()
_apply_camera(0.0)
retarget = SkeletonRetarget.new(vrm.skeleton, vrm.bones)
retarget.capture_rest()
@@ -198,17 +238,90 @@ func prepare(song: CatalogTypes.Song, motion: CatalogTypes.Motion, model: Catalo
return
var coverage := vrm.coverage() * 100.0
_set_status("模型就绪:%s(VRM %s,%d 骨骼,人形覆盖率 %.0f%%)" % [
_set_status("模型就绪:%s(VRM %s,%d 骨骼,人形覆盖率 %.0f%%)| %s" % [
model.name, vrm.vrm_version if vrm.vrm_version != "" else "—", vrm.bone_count, coverage,
scene_runtime.describe() if scene_runtime != null else "无场景",
])
if coverage < 60.0:
_grade_label.text = "骨骼覆盖偏低,动作可能不完整"
_show_scene_note(scene_warning)
state = State.IDLE
_start_button.disabled = false
_refresh_connection_hint()
func _show_scene_note(note: String) -> void:
if _scene_note == null:
return
_scene_note.visible = note != ""
_scene_note.text = "⚠ %s" % note if note != "" else ""
_scene_note.add_theme_color_override("font_color", UiKit.COLOR_BAD)
# ---------------- 场景(T-WIN-013) ----------------
## 载入场景并落地物理。返回非空字符串表示需要提示的降级原因(不阻塞开始跟练)。
func _load_scene(scene: CatalogTypes.Scene) -> String:
scene_warning = ""
var config: CatalogTypes.SceneConfig = null
if scene != null:
config = scene.config
if scene == null:
scene_runtime = SceneRuntime.create_empty(config)
scene_runtime.apply_environment(_world_env)
_scene_host.add_child(scene_runtime)
push_warning("[GameScreen] 未选择场景,使用内置空场景")
return "未选择场景,已使用内置空场景"
if not scene.is_usable():
scene_runtime = SceneRuntime.create_empty(config, scene.name)
scene_runtime.apply_environment(_world_env)
_scene_host.add_child(scene_runtime)
return "场景「%s」缺少 GLB 文件,已回退内置空场景" % scene.name
var cached: Dictionary = await CatalogStore.ensure_scene_cached(scene)
if not cached["ok"]:
scene_runtime = SceneRuntime.create_empty(config, scene.name)
scene_runtime.apply_environment(_world_env)
_scene_host.add_child(scene_runtime)
return "场景下载失败(%s),已回退内置空场景" % str(cached["error"])
scene_runtime = SceneRuntime.load_from_file(str(cached["path"]), config, scene.name)
scene_runtime.apply_environment(_world_env)
_scene_host.add_child(scene_runtime)
# 日志核对:重力 / 风 / 摩擦 / 碰撞体数量(文档 §5.6 第 3 条要求可核对)
print("[GameScreen] %s" % scene_runtime.describe())
if scene_runtime.warnings.size() > 0:
push_warning("[GameScreen] 场景降级:%s" % scene_runtime.warnings_text())
return scene_runtime.warnings_text()
return ""
## 角色落到场景出生点(含 yaw 朝向)
func _place_character_on_spawn() -> void:
if vrm == null or vrm.root == null:
return
var transform := Transform3D.IDENTITY
if scene_runtime != null:
transform = scene_runtime.spawn_transform(0)
vrm.root.transform = transform
## 按场景相机配置摆机位;无场景时退回原来的"框住模型"行为
func _apply_camera(time_seconds: float) -> void:
if vrm == null or vrm.root == null:
return
if scene_runtime != null:
var target := scene_runtime.spawn_transform(0).origin
# 以模型上半身为焦点,避免角色高出镜头
target.y += 1.0
scene_runtime.apply_camera(_camera, target, time_seconds)
return
_frame_camera_on(vrm.root)
## 让相机框住模型
func _frame_camera_on(root: Node3D) -> void:
var aabb := _compute_aabb(root)
@@ -347,9 +460,29 @@ func _clear_model() -> void:
retarget = null
func _clear_scene() -> void:
if scene_runtime != null:
if is_instance_valid(scene_runtime) and scene_runtime.get_parent() == _scene_host:
_scene_host.remove_child(scene_runtime)
scene_runtime.queue_free()
scene_runtime = null
scene_warning = ""
## 只在跟练进行中推进相机(orbit 模式需要时间轴),其余时候不动相机
func _process(delta: float) -> void:
if scene_runtime == null or vrm == null or _camera == null:
return
if scene_runtime.config == null or scene_runtime.config.camera.mode != "orbit":
return
_elapsed += delta
_apply_camera(_elapsed)
func _exit_tree() -> void:
if PoseReceiver.frame_received.is_connected(_on_frame_received):
PoseReceiver.frame_received.disconnect(_on_frame_received)
if ScoreService.score_updated.is_connected(_on_score_updated):
ScoreService.score_updated.disconnect(_on_score_updated)
_clear_model()
_clear_scene()
+4 -3
View File
@@ -94,10 +94,11 @@ func goto_about() -> void:
_show_only(_about)
func _on_start_session(song: Object, motion: Object, model: Object) -> void:
func _on_start_session(song: Object, motion: Object, model: Object, scene: Object = null) -> void:
_show_only(_game)
_game.prepare(song, motion, model)
set_status("准备跟练:%s" % (motion.name if motion != null else "未选择动作"))
_game.prepare(song, motion, model, scene)
var scene_name: String = scene.name if scene != null else "内置空场景"
set_status("准备跟练:%s | 场景 %s" % [(motion.name if motion != null else "未选择动作"), scene_name])
func _on_session_finished(summary: Dictionary, source: Object, asset_name: String) -> void:
+67 -12
View File
@@ -1,17 +1,21 @@
class_name MainMenuScreen
extends Control
## T-WIN-008:主界面(曲目/动作选择 + 连接状态 + 设置)
## T-WIN-008:主界面(曲目/动作/模型选择 + 连接状态 + 设置)
## T-WIN-009:来源信息展示
## T-WIN-013:场景选择(与曲目/动作/模型并列的第 4 个选择器)
const UiKit = preload("res://src/ui/ui_kit.gd")
const CatalogTypes = preload("res://src/net/catalog_types.gd")
const SourcePanel = preload("res://src/ui/source_panel.gd")
const ScenePanel = preload("res://src/ui/scene_panel.gd")
var _song_list: ItemList
var _motion_list: ItemList
var _model_list: ItemList
var _scene_list: ItemList
var _source_panel: SourcePanel
var _scene_panel: ScenePanel
var _status: Label
var _health: Label
var _api_input: LineEdit
@@ -20,8 +24,10 @@ var _start_button: Button
var _songs: Array = []
var _motions: Array = []
var _models: Array = []
var _scenes: Array = []
var _selected_motion: CatalogTypes.Motion = null
var _selected_model: CatalogTypes.Model = null
var _selected_scene: CatalogTypes.Scene = null
var _on_start: Callable = Callable()
var _on_about: Callable = Callable()
@@ -66,30 +72,42 @@ func _ready() -> void:
api_row.add_child(listen)
root.add_child(api_row)
# ---- 三栏选择 ----
var columns := UiKit.hbox(12)
columns.size_flags_vertical = Control.SIZE_EXPAND_FILL
# ---- 四栏选择(2×2,窗口 1280 宽下每列仍可读) ----
var grid := GridContainer.new()
grid.columns = 2
grid.add_theme_constant_override("h_separation", 12)
grid.add_theme_constant_override("v_separation", 10)
grid.size_flags_vertical = Control.SIZE_EXPAND_FILL
grid.size_flags_horizontal = Control.SIZE_EXPAND_FILL
var song_column := _make_list_column("1. 选择曲目")
_song_list = song_column["list"]
columns.add_child(_wrap_in_panel(song_column["box"]))
grid.add_child(_wrap_in_panel(song_column["box"]))
var motion_column := _make_list_column("2. 选择动作(已完成预处理)")
_motion_list = motion_column["list"]
columns.add_child(_wrap_in_panel(motion_column["box"]))
grid.add_child(_wrap_in_panel(motion_column["box"]))
var model_column := _make_list_column("3. 选择模型(VRM 优先)")
_model_list = model_column["list"]
columns.add_child(_wrap_in_panel(model_column["box"]))
grid.add_child(_wrap_in_panel(model_column["box"]))
root.add_child(columns)
var scene_column := _make_list_column("4. 选择场景(GLB + 物理参数)")
_scene_list = scene_column["list"]
grid.add_child(_wrap_in_panel(scene_column["box"]))
# ---- 来源 + 操作 ----
root.add_child(grid)
# ---- 来源 + 场景参数 + 操作 ----
var bottom := UiKit.hbox(14)
_source_panel = SourcePanel.new("动作来源")
_source_panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
bottom.add_child(_source_panel)
_scene_panel = ScenePanel.new("场景参数")
_scene_panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
bottom.add_child(_scene_panel)
var actions := UiKit.vbox(8)
_start_button = UiKit.button("开始跟练", _on_start_pressed)
_start_button.custom_minimum_size = Vector2(180, 46)
@@ -106,6 +124,7 @@ func _ready() -> void:
_song_list.item_selected.connect(_on_song_selected)
_motion_list.item_selected.connect(_on_motion_selected)
_model_list.item_selected.connect(_on_model_selected)
_scene_list.item_selected.connect(_on_scene_selected)
CatalogStore.catalog_loaded.connect(_rebuild_lists)
CatalogStore.catalog_failed.connect(_on_catalog_failed)
@@ -164,6 +183,7 @@ func _rebuild_lists() -> void:
_songs = CatalogStore.songs()
_motions = CatalogStore.motions()
_models = CatalogStore.models()
_scenes = CatalogStore.scenes()
_song_list.clear()
for song in _songs:
@@ -190,13 +210,39 @@ func _rebuild_lists() -> void:
_model_list.select(index)
_selected_model = _models[index]
_set_status("目录已加载:%d 首曲目 | %d 个动作 | %d 个模型(契约 v%s)" % [
_songs.size(), _motions.size(), _models.size(),
_rebuild_scene_list()
_set_status("目录已加载:%d 首曲目 | %d 个动作 | %d 个模型 | %d 个场景(契约 v%s)" % [
_songs.size(), _motions.size(), _models.size(), _scenes.size(),
str(CatalogStore.contract_version.get("data-models", "?")),
])
_update_start_state()
## T-WIN-013:场景列表;无可用场景时提示并回退内置空场景(不阻塞开始跟练)
func _rebuild_scene_list() -> void:
_scene_list.clear()
for scene in _scenes:
var tag := "" if scene.is_usable() else " ⚠ 缺少 GLB"
_scene_list.add_item("%s [%s]%s" % [scene.name, scene.size_label(), tag])
if _scenes.is_empty():
_selected_scene = null
_scene_panel.clear()
return
var default_scene := CatalogStore.default_scene()
var index := 0
if default_scene != null:
for i in _scenes.size():
if _scenes[i].id == default_scene.id:
index = i
break
_scene_list.select(index)
_selected_scene = _scenes[index]
_scene_panel.show_scene(_selected_scene)
func _rebuild_motion_list(song_index: int) -> void:
_motion_list.clear()
var filtered: Array = []
@@ -249,6 +295,15 @@ func _on_model_selected(index: int) -> void:
_update_start_state()
func _on_scene_selected(index: int) -> void:
if index >= 0 and index < _scenes.size():
_selected_scene = _scenes[index]
_scene_panel.show_scene(_selected_scene)
if not _selected_scene.is_usable():
_set_status("场景「%s」缺少 GLB 文件,将回退到内置空场景" % _selected_scene.name)
_update_start_state()
func _update_start_state() -> void:
_start_button.disabled = _selected_motion == null
@@ -259,7 +314,7 @@ func _on_start_pressed() -> void:
var song = null
if _selected_motion.song_id >= 0:
song = CatalogStore.bundle.find_song(_selected_motion.song_id) if CatalogStore.bundle != null else null
_on_start.call(song, _selected_motion, _selected_model)
_on_start.call(song, _selected_motion, _selected_model, _selected_scene)
func _apply_api_base() -> void:
+147
View File
@@ -0,0 +1,147 @@
class_name ScenePanel
extends PanelContainer
## T-WIN-013:场景选择后的**缩略图 + 参数摘要**面板(文档 §5.6 第 2 条)。
##
## 服务端只存参数、仿真在本端,因此选择场景后必须让用户直接看到
## 「重力 / 风 / 摩擦」这些真正会生效的数值,而不是只给一个名字。
const UiKit = preload("res://src/ui/ui_kit.gd")
const CatalogTypes = preload("res://src/net/catalog_types.gd")
const THUMBNAIL_SIZE := Vector2(150, 90)
var _thumbnail: TextureRect
var _placeholder: Label
var _name_label: Label
var _summary_label: Label
var _detail_label: Label
var _warning: Label
var _pending_url: String = ""
func _init(title: String = "场景参数") -> void:
var style := StyleBoxFlat.new()
style.bg_color = UiKit.COLOR_BG
style.corner_radius_top_left = 8
style.corner_radius_top_right = 8
style.corner_radius_bottom_left = 8
style.corner_radius_bottom_right = 8
style.content_margin_left = 12
style.content_margin_right = 12
style.content_margin_top = 8
style.content_margin_bottom = 8
add_theme_stylebox_override("panel", style)
var box := UiKit.vbox(6)
add_child(box)
box.add_child(UiKit.label(title, UiKit.FONT_SMALL, UiKit.COLOR_ACCENT))
var row := UiKit.hbox(10)
var thumb_holder := Control.new()
thumb_holder.custom_minimum_size = THUMBNAIL_SIZE
_thumbnail = TextureRect.new()
_thumbnail.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
_thumbnail.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
_thumbnail.set_anchors_preset(Control.PRESET_FULL_RECT)
_thumbnail.visible = false
thumb_holder.add_child(_thumbnail)
_placeholder = UiKit.label("无缩略图", UiKit.FONT_SMALL, UiKit.COLOR_MUTED)
_placeholder.set_anchors_preset(Control.PRESET_FULL_RECT)
_placeholder.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_placeholder.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
thumb_holder.add_child(_placeholder)
row.add_child(thumb_holder)
var texts := UiKit.vbox(3)
texts.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_name_label = UiKit.label("—", UiKit.FONT_BODY)
texts.add_child(_name_label)
_summary_label = UiKit.label("", UiKit.FONT_SMALL, UiKit.COLOR_MUTED)
_summary_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
texts.add_child(_summary_label)
_detail_label = UiKit.label("", UiKit.FONT_SMALL, UiKit.COLOR_MUTED)
_detail_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
texts.add_child(_detail_label)
row.add_child(texts)
box.add_child(row)
_warning = UiKit.label("", UiKit.FONT_SMALL, UiKit.COLOR_BAD)
_warning.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_warning.visible = false
box.add_child(_warning)
clear()
func clear() -> void:
_pending_url = ""
_name_label.text = "—"
_summary_label.text = "未选择场景时使用内置空场景(默认重力与无风)"
_detail_label.text = ""
_warning.visible = false
_thumbnail.texture = null
_thumbnail.visible = false
_placeholder.visible = true
_placeholder.text = "无缩略图"
## 展示一个场景:立即渲染参数摘要,缩略图异步补齐
func show_scene(scene: CatalogTypes.Scene) -> void:
if scene == null:
clear()
return
_name_label.text = "%s | %s" % [scene.name, scene.size_label()]
var cfg: CatalogTypes.SceneConfig = scene.config
_summary_label.text = cfg.summary() if cfg != null else "物理参数缺失(按默认值处理)"
var details := PackedStringArray()
details.append("环境光 %s(强度 %.2f)| 雾 %.2f" % [
"#" + (cfg.environment.ambient_color.to_html(false) if cfg != null else "8fb4ff"),
cfg.environment.ambient_intensity if cfg != null else 1.0,
cfg.environment.fog_density if cfg != null else 0.0,
])
details.append("出生点 %d 个 | 相机 %s(距离 %.1f,高度 %.1f)" % [
cfg.spawn_points.size() if cfg != null else 0,
cfg.camera.label() if cfg != null else "跟随",
cfg.camera.distance if cfg != null else 4.5,
cfg.camera.height if cfg != null else 1.6,
])
if scene.conversion_note != "":
details.append("转换说明:%s" % scene.conversion_note)
if not scene.source.is_complete():
details.append("⚠ 来源五元组不齐备")
_detail_label.text = "\n".join(details)
if not scene.is_usable():
_warning.visible = true
_warning.text = "⚠ 该场景缺少 GLB 文件,开始跟练时将回退到内置空场景"
else:
_warning.visible = false
_thumbnail.texture = null
_thumbnail.visible = false
_placeholder.visible = true
if scene.thumbnail_url == "":
_placeholder.text = "无缩略图"
return
_placeholder.text = "缩略图加载中…"
_pending_url = scene.thumbnail_url
_load_thumbnail(scene.thumbnail_url)
func _load_thumbnail(url: String) -> void:
var texture: Texture2D = await CatalogStore.fetch_thumbnail(url)
# 异步返回时用户可能已经切走,丢弃过期结果
if _pending_url != url or not is_instance_valid(self):
return
if texture == null:
_placeholder.text = "缩略图加载失败"
return
_thumbnail.texture = texture
_thumbnail.visible = true
_placeholder.visible = false
+1
View File
@@ -0,0 +1 @@
uid://ctm7k5ax7nrha
+42
View File
@@ -0,0 +1,42 @@
# T-WIN-012 「关于」清单展示当前版本号
- **状态**: done
- **依赖**: T-WIN-010
- **文档**: v1.6 §11(版本号展示)
## 目标
Windows 客户端在**「关于」清单**中展示当前运行版本号,且版本号只有一处权威定义。
## 方案
| 项 | 做法 |
|--|--|
| 唯一来源 | `project.godot` 的 `application/config/version`(运行期用 `ProjectSettings.get_setting` 读取) |
| 界面位置 | 关于页面新增「清单」区块,逐行「标签:值」 |
| 禁止重复 | 界面代码中**不出现版本号字面量**;由 `test_about.gd` 反例防护 |
`src/ui/about_screen.gd` 新增两个可直接单测的纯函数:
- `app_version()` —— 返回 `vX.Y.Z` 的来源值(唯一来源);
- `about_manifest()` —— 返回清单各行(版本 / 引擎 / 契约版本 / 接收协议 / 接收地址 / 服务端地址 / 隐私说明)。
`about_manifest()` 读取 `AppConfig` 时走 `get_node_or_null("AppConfig")`,
因为无头测试以 `--script` 运行**没有 autoload**,直接引用会在解析期报错。
## 顺带修正:契约版本不能再用单个常量
`Credits.CONTRACT_VERSION` 原为单个常量 `0.1.0`,但六项契约现在各自独立递增
(`rest-api 0.5.0` / `data-models 0.7.0` / `skeleton-topology 0.2.0` …)。
改为 `Credits.CONTRACT_VERSIONS` 字典 + `version_of()` / `summary()`,
并让 `test_credits.gd` 逐项与 `contracts/VERSION` 双向核对
(新增契约若忘记同步展示,测试直接失败)。
## 验收(文档 §11.3)
| 验收项 | 结果 |
|--|--|
| 关于清单可见 `vX.Y.Z` | ✅ 清单首行「客户端版本:v0.2.0」 |
| 与导出产物文件名一致 | ✅ `opendance-windows-v0.2.0.zip` |
| 与 `project.yaml` / `project.godot` 一致 | ✅ 测试 `test_version_is_semver_and_matches_project_yaml` 核对 |
| 界面代码无版本号字面量 | ✅ 测试 `test_version_not_hardcoded_in_ui_sources` 核对 |
+94
View File
@@ -0,0 +1,94 @@
# T-WIN-013 API 连接升级与场景选择
- **状态**: done
- **依赖**: T-WIN-002、T-WIN-008
- **文档**: v1.6 §5.6、契约 `rest-api 0.5.0` / `data-models 0.7.0`
## 目标
跟随 Web 端场景管理(T-WEB-015)升级本端 API 连接,新增**场景选择**与**物理参数落地**。
## 交付物
| 文件 | 说明 |
|--|--|
| `contracts/*` | 契约快照同步到 `rest-api 0.5.0` / `data-models 0.7.0` / `database 0.4.0`(与 opendance-web 逐字节一致) |
| `src/net/catalog_types.gd` | 新增 `Wind` / `Ground` / `ScenePhysics` / `SceneEnvironment` / `SpawnPoint` / `SceneCamera` / `SceneConfig` / `Scene`;`CatalogBundle` 增加 `scenes` |
| `src/scene/scene_runtime.gd` | **场景实例化与物理落地**(新增目录 `src/scene/`) |
| `src/autoload/catalog_store.gd` | `scenes()` / `default_scene()` / `find_scene()` / `ensure_scene_cached()` / `fetch_thumbnail()` |
| `src/ui/scene_panel.gd` | 场景缩略图 + 重力/风/摩擦参数摘要 |
| `src/ui/main_menu.gd` | 第 4 个选择器「场景」(2×2 栅格,与曲目/动作/模型并列) |
| `src/ui/game_screen.gd` | 载入场景 → 落地物理 → 出生点定位 → 按场景相机取景 |
## 1. 契约解析与服务端同规则收敛
`SceneConfig.from_dict()` 复刻服务端 `normalizeSceneConfig()` 的规则:
缺项用默认值、越界夹紧、非法枚举回退。**服务端已收敛,客户端仍再收敛一次** ——
不能假设对端一定合法(旧版本服务端 / 人工改包)。
| 项 | 范围 |
|--|--|
| `gravity` | 各分量 ±200 |
| `wind.base` | ±100 |
| `gustAmplitude` / `gustFrequency` | 0~100 / 0.01~5 Hz |
| `gustDirection` | ±1 |
| `ground.friction` / `restitution` / `slopeLimit` | 0~1 / 0~1 / 0~89 度 |
| `environment.ambientIntensity` / `fogDensity` | 0~4 / 0~1 |
| `camera.distance` / `height` | 0.5~50 / 0~20 |
| `spawnPoints` | 最多 16 个 |
## 2. 物理落地(服务端只存参数,仿真在本端)
- **重力**:`Area3D` + `gravity_space_override = SPACE_OVERRIDE_REPLACE`,
**不改 `ProjectSettings` 的全局重力**,因此一个进程内切换场景互不影响。
- **碰撞体**:`colliders: mesh` → 所有 `MeshInstance3D` 走 `create_trimesh_collision()`;
`auto` → 只认命名约定(`*_col` / `*_colonly` / `*_collision` / …),
`*colonly` 只提供碰撞不参与渲染(对齐 Godot/Blender 约定);
`none` → 不生成任何碰撞体,**也不兜底**(尊重场景作者意图)。
`auto`/`mesh` 下一个都没找到时生成兜底地面并记录降级原因,避免角色无限下落。
- **风**:`Area3D` + 每物理帧对区域内 `RigidBody3D` `apply_central_force()`;
阵风按正弦叠加(`base + dir * A * sin(2πft)`),`space: local` 时用本节点基向量变换。
风速(m/s)→ 力(N)用固定系数 `WIND_FORCE_SCALE = 10`(契约只约束风速量纲)。
- **地面材质**:`friction` / `restitution` → `PhysicsMaterial`,套用到所有场景静态体。
- **环境**:环境光颜色/强度直接写进游戏界面已有的 `Environment`(不另建
`WorldEnvironment`,避免互相覆盖);`fogDensity 0~1` 映射到 Godot 的
`0.005~0.05`(Godot 的 `fog_density` 量纲更敏感,直接照搬会全屏糊死)。
- **出生点与相机**:角色落在 `spawnPoints[0]`(含 yaw),按 `camera.mode` 取景
(`follow` / `fixed` / `orbit`,orbit 在 `_process` 中缓慢环绕)。
## 3. 容错(不得阻塞开始跟练)
| 情况 | 行为 |
|--|--|
| 未选择场景 / 服务端无已发布场景 | 内置空场景 + 状态栏提示 |
| 场景缺少 `glbUrl` | 回退内置空场景 + 界面提示 |
| GLB 下载失败 | 回退内置空场景 + 明确原因 |
| GLB 解析失败 / 根节点非 Node3D | 回退内置空场景 + `warnings` |
| 物理参数非法 | 收敛为默认值(不抛错) |
| 无碰撞体可用 | 兜底地面 + 降级提示 |
## 4. 可核对性
`SceneRuntime.describe()` 输出一行摘要(重力 / 风 / 摩擦 / 弹性 / 碰撞体数量 / 相机模式),
启动时 `print` 到日志;界面同时展示同源参数。满足文档「目视 + 日志核对」。
## 验收(文档 §5.6)
| 验收项 | 结果 |
|--|--|
| 服务端发布新场景后「刷新目录」即可见并可选 | ✅ `catalog_export.scenes[]` → 第 4 个选择器;目录指纹变化即重新拉取 |
| 场景 GLB 正常加载 | ✅ `GLTFDocument` 与 `VrmLoader` 同一条零原生依赖路线 |
| 重力与风向参数按服务端配置生效(目视 + 日志核对) | ✅ `describe()` 打印 + 界面参数摘要;受力计算有单测逐值核对 |
| 服务端不可达 / 场景资产缺失时明确提示并回退,不崩溃 | ✅ 见上表「容错」;`test_scene_runtime_falls_back_when_file_missing` 覆盖 |
## 测试
`tests/test_scene_config.gd`(12 个用例 / 60+ 断言):
- 默认值逐项与服务端 `SCENE_CONFIG_DEFAULTS` 一致;
- 越界夹紧、非法枚举回退、非数值兜底、出生点 16 上限;
- 风的正弦叠加逐时刻核对(t=0 / 1(4f) / 1(2f) / 3(4f));
- 目录 `scenes[]` 解析与 `default_scene()` 跳过缺 GLB 的场景;
- 重力与风力的纯函数计算、空场景兜底地面、`colliders: none` 不兜底;
- 文件缺失时的降级路径、出生点位姿(yaw 90° 使 +Z 转向 +X)、
相机 `fixed` 机位、雾浓度映射区间。
+74
View File
@@ -0,0 +1,74 @@
# T-WIN-014 发布交付:必须上传完整包
- **状态**: done
- **依赖**: T-WIN-011
- **文档**: v1.6 §5.7
## 问题
`v0.1.0` 发布到 Gitea 的 Release 资产是
```
opendance-windows-v0.1.0.zip.part0
opendance-windows-v0.1.0.zip.part1
opendance-windows-v0.1.0.zip.part2
REASSEMBLE.txt
SHA256SUMS.txt
```
用户下载到的是**三个碎片**,必须先按 `REASSEMBLE.txt` 拼接再解压 —— 这就是"分卷有问题"。
## 根因(实测)
| 上传体积 | 结果 |
|--|--|
| 14 MB | 通过(HTTP 201) |
| 30.5 MB | **失败 HTTP 413**(IIS「请求实体过大」页面) |
| 40 MB | **失败 HTTP 413** |
→ 当前上限是 **IIS 默认的 `maxAllowedContentLength = 30,000,000` 字节(≈28.6 MiB)**。
整包约 38 MB(`exe` ≈104 MB 未压缩、`pck` ≈107 KB),当时被拆成每卷 13 MiB 才传上去。
## 方案(采用 A:提高上限)
- **A. 提高上限**(推荐,配置一次永久解决):IIS `maxAllowedContentLength = 268435456`(256 MB),
ARR `maxRequestBytes` 同步放宽,Gitea `app.ini` `[attachment] MAX_SIZE ≥ 256 MB`,
前置 nginx(若有)`client_max_body_size 256m;`。做法见
`opendance-web/deploy/proxy/README.md`。
- B. 控制包体 ≤ 28 MB 与 C. 改分发方式(Git LFS / 走 Web 服务端 `/static`)作为备选保留。
## 本次改动
`build/package-release.sh` 重写,落实文档 §5.7 的四条硬性要求:
1. **单个完整包**:产物只有 `opendance-windows-vX.Y.Z.zip` + `SHA256SUMS`;
脚本开头 `rm -f "$NAME.zip".part*` 清理历史分卷,
并且**超限时直接报警而绝不回退分卷**。
2. **`SHA256SUMS` 用裸文件名**,用户下载到同一目录后 `sha256sum -c SHA256SUMS` 直接可用。
3. **体积与完整性自检**:
- 源产物:`exe` 校验 PE 头 `MZ`、`pck` 校验魔数 `GDPC`;
- 解压后:必须同时存在 `OpenDance.exe` + `OpenDance.pck`(同目录)、
`README.txt`、`THIRD_PARTY.md`、`contracts/`,并再次校验 `MZ` / `GDPC`;
- `exe` 与 `pck` 主文件名必须一致(否则 Godot 找不到资源);
- 上传前打印包体大小与 IIS 上限的对比结论。
4. **记录实测上限**:脚本内置 `UPLOAD_LIMIT_BYTES=30000000` 并在发布说明中写明,
避免下次再踩。
## 验收(文档 §5.7)
| 验收项 | 结果 |
|--|--|
| Release 资产只有一个 `opendance-windows-vX.Y.Z.zip` + `SHA256SUMS` | ✅ 见 `dist-release/` |
| 下载后可直接解压运行(`OpenDance.exe` + `.pck` 同目录),无需拼接 | ✅ 脚本解压自检覆盖 |
| `sha256sum -c SHA256SUMS` 通过 | ✅ 脚本打包后立即在同一目录自校验 |
## 上传前检查清单
```
1) bash build/export-windows.sh release # 生成 exe + pck
2) bash build/package-release.sh 0.2.0 # 单包 + SHA256SUMS + 自检
3) 看输出的「包体 / 上限」对比:
- 低于 30,000,000 字节 -> 直接上传;
- 超过 -> 先请运维放宽 IIS/ARR/Gitea 上限,不要拆卷。
4) Gitea Release 只上传 opendance-windows-vX.Y.Z.zip 与 SHA256SUMS 两个资产。
```
+64
View File
@@ -0,0 +1,64 @@
extends TestCase
## T-WIN-012:版本号展示(文档 §11)
##
## 硬性要求是「单一来源 + 可核对」:
## 版本号只允许有一处权威定义(project.godot 的 application/config/version),
## 界面从该来源读取,且必须与 project.yaml / 产物文件名 / Git tag 一致。
## 因此这里同时核对 project.godot、project.yaml、README 三处。
const AboutScreen = preload("res://src/ui/about_screen.gd")
const Credits = preload("res://src/ui/credits.gd")
func _read_text(path: String) -> String:
if not FileAccess.file_exists(path):
return ""
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
return ""
var text := file.get_as_text()
file.close()
return text
func test_version_is_semver_and_matches_project_yaml() -> void:
var version := AboutScreen.app_version()
var parts := version.split(".")
eq(parts.size(), 3, "版本号形如 X.Y.Z(实际 %s)" % version)
for part in parts:
ok(part.is_valid_int(), "版本号各段为整数(%s)" % part)
var yaml := _read_text("res://project.yaml")
ne(yaml, "", "可读取 project.yaml")
contains(yaml, "version: %s" % version, "project.yaml 的 version 与 project.godot 一致")
func test_about_manifest_lists_version() -> void:
var manifest := AboutScreen.about_manifest()
var found := {}
for item in manifest:
found[str(item["label"])] = str(item["value"])
ok(found.has("客户端版本"), "关于清单含「客户端版本」一项")
eq(found.get("客户端版本", ""), "v%s" % AboutScreen.app_version(), "清单版本号与唯一来源一致")
ok(found.has("契约版本"), "关于清单含「契约版本」")
for key in Credits.CONTRACT_VERSIONS:
contains(str(found.get("契约版本", "")), "%s v%s" % [key, Credits.CONTRACT_VERSIONS[key]], "清单含 %s 契约版本" % key)
ok(str(found.get("隐私说明", "")).contains("不存储"), "关于清单声明不存储视频画面")
func test_version_not_hardcoded_in_ui_sources() -> void:
# 反例防护:界面代码里不允许再手写一份版本号(文档 §11.2 第 1 条)
var version := AboutScreen.app_version()
for path in ["res://src/ui/about_screen.gd", "res://src/ui/main_menu.gd", "res://src/ui/game_screen.gd"]:
var text := _read_text(path)
ne(text, "", "可读取 %s" % path)
is_false(text.contains("\"%s\"" % version), "%s 未硬编码版本号字面量" % path.get_file())
func test_entry_scene_version_is_exported_into_build() -> void:
# 导出产物名必须能由版本号推导(Documentation §11.2 第 2 条)
var version := AboutScreen.app_version()
contains("opendance-windows-v%s.zip" % version, version, "产物文件名包含版本号")
ok(Credits.entry_count() >= 10, "开源声明条目仍齐备")
+1
View File
@@ -0,0 +1 @@
uid://ctlu5y6aqcngp
+17 -5
View File
@@ -50,7 +50,8 @@ func test_bbcode_contains_all_entries_and_no_smpl() -> void:
contains(text, str(entry["url"]), "BBCode 含 %s 的链接" % str(entry["name"]))
contains(text, "SMPL", "BBCode 明确声明未使用 SMPL")
contains(text, "第三方开源项目声明", "BBCode 含声明标题")
contains(text, Credits.CONTRACT_VERSION, "BBCode 含契约版本")
for key in Credits.CONTRACT_VERSIONS:
contains(text, "%s v%s" % [key, Credits.CONTRACT_VERSIONS[key]], "BBCode 含 %s 契约版本" % key)
func test_plain_text_export_is_parseable() -> void:
@@ -62,7 +63,7 @@ func test_plain_text_export_is_parseable() -> void:
eq(parts.size(), 3, "每行三段:名称 | 许可证 | 链接")
func test_contract_version_matches_snapshot() -> void:
func test_contract_versions_match_snapshot() -> void:
# contracts/VERSION 是唯一来源;本端 README/关于页/THIRD_PARTY 都必须与之一致
var file := FileAccess.open("res://contracts/VERSION", FileAccess.READ)
if file == null:
@@ -70,6 +71,17 @@ func test_contract_version_matches_snapshot() -> void:
return
var content := file.get_as_text()
file.close()
contains(content, "rest-api: %s" % Credits.CONTRACT_VERSION, "契约快照 rest-api 版本一致")
contains(content, "websocket-keypoints: %s" % Credits.CONTRACT_VERSION, "契约快照 websocket-keypoints 版本一致")
contains(content, "skeleton-topology: %s" % Credits.CONTRACT_VERSION, "契约快照 skeleton-topology 版本一致")
# 六项各自独立递增,逐项核对(不能用单个常量)
for key in Credits.CONTRACT_VERSIONS:
contains(content, "%s: %s" % [key, Credits.CONTRACT_VERSIONS[key]], "契约快照 %s 版本一致" % key)
# 反向核对:快照里的每一项都必须在代码里有对应常量,避免新增契约漏同步
for line in content.split("\n"):
var trimmed := line.strip_edges()
if trimmed == "" or trimmed.begins_with("#"):
continue
var parts := trimmed.split(":")
eq(parts.size(), 2, "VERSION 行格式为 name: version(%s)" % trimmed)
if parts.size() == 2:
var name := parts[0].strip_edges()
var version := parts[1].strip_edges()
eq(Credits.version_of(name), version, "契约快照新增项 %s 已同步到 Credits" % name)
+245
View File
@@ -0,0 +1,245 @@
extends TestCase
## T-WIN-013:场景契约解析 + 物理参数落地
##
## 两件事必须覆盖:
## 1. `SceneConfig.from_dict` 的**边界收敛必须与服务端 normalizeSceneConfig 同规则**
## (服务端已收敛,但客户端不能假设对端一定合法:旧版本服务端 / 人工改包);
## 2. 重力与风的**受力计算是纯函数**,可以在无头环境下逐值核对。
const CatalogTypes = preload("res://src/net/catalog_types.gd")
const SceneRuntime = preload("res://src/scene/scene_runtime.gd")
func test_defaults_match_server_frozen_defaults() -> void:
var cfg: CatalogTypes.SceneConfig = CatalogTypes.SceneConfig.from_dict({})
almost(cfg.physics.gravity.x, 0.0, 1e-6, "默认重力 x = 0")
almost(cfg.physics.gravity.y, -9.8, 1e-6, "默认重力 y = -9.8")
almost(cfg.physics.gravity.z, 0.0, 1e-6, "默认重力 z = 0")
almost(cfg.physics.ground.friction, 0.6, 1e-6, "默认摩擦 = 0.6")
almost(cfg.physics.ground.restitution, 0.0, 1e-6, "默认弹性 = 0")
almost(cfg.physics.ground.slope_limit, 45.0, 1e-6, "默认坡度上限 = 45")
eq(cfg.physics.colliders, "auto", "默认碰撞体来源 = auto")
almost(cfg.physics.wind.gust_frequency, 0.2, 1e-6, "默认阵风频率 = 0.2Hz")
almost(cfg.physics.wind.gust_amplitude, 0.0, 1e-6, "默认无阵风")
eq(cfg.physics.wind.space, "world", "默认风坐标系 = world")
almost(cfg.environment.ambient_intensity, 1.0, 1e-6, "默认环境光强度 = 1")
almost(cfg.environment.fog_density, 0.0, 1e-6, "默认关闭雾")
eq(cfg.spawn_points.size(), 1, "默认 1 个出生点")
eq(cfg.camera.mode, "follow", "默认相机模式 = follow")
almost(cfg.camera.distance, 4.5, 1e-6, "默认相机距离 = 4.5")
almost(cfg.camera.height, 1.6, 1e-6, "默认相机高度 = 1.6")
func test_out_of_range_values_are_clamped() -> void:
var cfg: CatalogTypes.SceneConfig = CatalogTypes.SceneConfig.from_dict({
"physics": {
"gravity": {"x": 9999.0, "y": -9999.0, "z": 0.0},
"wind": {"base": {"x": 500.0, "y": 0.0, "z": 0.0}, "gustAmplitude": 500.0, "gustFrequency": 0.0, "gustDirection": {"x": 9.0, "y": 0.0, "z": 0.0}},
"ground": {"friction": 5.0, "restitution": -3.0, "slopeLimit": 100.0},
},
"environment": {"ambientIntensity": 9.0, "fogDensity": 3.0},
"camera": {"distance": 0.01, "height": 100.0},
})
almost(cfg.physics.gravity.x, 200.0, 1e-6, "重力 x 夹紧到 +200")
almost(cfg.physics.gravity.y, -200.0, 1e-6, "重力 y 夹紧到 -200")
almost(cfg.physics.wind.base.x, 100.0, 1e-6, "基础风夹紧到 +100")
almost(cfg.physics.wind.gust_amplitude, 100.0, 1e-6, "阵风幅度夹紧到 100")
almost(cfg.physics.wind.gust_frequency, 0.01, 1e-6, "阵风频率夹紧到下限 0.01Hz")
almost(cfg.physics.wind.gust_direction.x, 1.0, 1e-6, "阵风方向夹紧到 ±1")
almost(cfg.physics.ground.friction, 1.0, 1e-6, "摩擦夹紧到 1")
almost(cfg.physics.ground.restitution, 0.0, 1e-6, "弹性夹紧到 0")
almost(cfg.physics.ground.slope_limit, 89.0, 1e-6, "坡度上限夹紧到 89")
almost(cfg.environment.ambient_intensity, 4.0, 1e-6, "环境光强度夹紧到 4")
almost(cfg.environment.fog_density, 1.0, 1e-6, "雾浓度夹紧到 1")
almost(cfg.camera.distance, 0.5, 1e-6, "相机距离夹紧到 0.5")
almost(cfg.camera.height, 20.0, 1e-6, "相机高度夹紧到 20")
func test_illegal_enums_and_garbage_fall_back() -> void:
var cfg: CatalogTypes.SceneConfig = CatalogTypes.SceneConfig.from_dict({
"physics": {
"gravity": {"x": "不是数字", "y": null},
"wind": {"space": "太空", "gustFrequency": 99.0},
"colliders": "螺旋",
},
"environment": {"ambientColor": "红色"},
"camera": {"mode": "自动"},
"spawnPoints": [],
})
almost(cfg.physics.gravity.x, 0.0, 1e-6, "非数值重力回退默认值")
almost(cfg.physics.gravity.y, -9.8, 1e-6, "null 重力回退默认值")
eq(cfg.physics.wind.space, "world", "非法风的坐标系回退 world")
almost(cfg.physics.wind.gust_frequency, 5.0, 1e-6, "越界阵风频率夹紧到 5Hz")
eq(cfg.physics.colliders, "auto", "非法 colliders 回退 auto")
eq(cfg.camera.mode, "follow", "非法相机模式回退 follow")
eq(cfg.environment.ambient_color.to_html(false), "8fb4ff", "非法环境光颜色回退默认 #8fb4ff")
eq(cfg.spawn_points.size(), 1, "空出生点数组回退默认 1 个")
func test_spawn_points_are_capped_at_16() -> void:
var raw: Array = []
for i in 30:
raw.append({"x": i, "y": 0, "z": 0, "yaw": i * 10})
var cfg: CatalogTypes.SceneConfig = CatalogTypes.SceneConfig.from_dict({"spawnPoints": raw})
eq(cfg.spawn_points.size(), 16, "出生点最多 16 个")
almost(cfg.spawn_points[0].position.x, 0.0, 1e-6, "第一个出生点 x = 0")
almost(cfg.spawn_points[15].position.x, 15.0, 1e-6, "第 16 个出生点 x = 15")
almost(cfg.spawn_points[15].yaw, 150.0, 1e-6, "第 16 个出生点 yaw = 150")
func test_wind_sampling_superposes_gust_sine() -> void:
var calm: CatalogTypes.Wind = CatalogTypes.Wind.from_dict({})
is_true(calm.is_calm(), "全零风判定为无风")
is_true(calm.sample(1.0).is_zero_approx(), "无风时任意时刻风力为 0")
var steady: CatalogTypes.Wind = CatalogTypes.Wind.from_dict({"base": {"x": 2.0, "y": 0.0, "z": -1.0}})
is_false(steady.is_calm(), "有基础风时不是无风")
almost(steady.sample(3.0).x, 2.0, 1e-6, "基础风不随时间变化")
almost(steady.sample(3.0).z, -1.0, 1e-6, "基础风 z 分量保持")
# 阵风:t=0 时 sin=0(只有基础风),t=1/(4f) 时为峰值
var gusty: CatalogTypes.Wind = CatalogTypes.Wind.from_dict({
"gustAmplitude": 4.0, "gustFrequency": 0.5, "gustDirection": {"x": 0.0, "y": 1.0, "z": 0.0},
})
almost(gusty.sample(0.0).y, 0.0, 1e-6, "阵风 t=0 时为 0(正弦起点)")
almost(gusty.sample(0.5).y, 4.0, 1e-5, "阵风 t=1/(4f) 达到峰值幅度")
almost(gusty.sample(1.0).y, 0.0, 1e-5, "阵风 t=1/(2f) 回到 0")
almost(gusty.sample(1.5).y, -4.0, 1e-5, "阵风半周期后反向")
func test_scene_parses_catalog_payload() -> void:
var scene: CatalogTypes.Scene = CatalogTypes.Scene.from_dict({
"id": 7,
"name": "舞台",
"glbUrl": "/static/scenes/7.glb",
"thumbnailUrl": "/static/scenes/7.png",
"fileSize": 2048,
"fileSha256": "abcdef0123456789",
"conversionNote": "由 .zip 转换",
"config": {"physics": {"gravity": {"x": 0.0, "y": -3.0, "z": 0.0}}},
"sourceType": "original",
"sourceName": "自建",
"sourceUrl": "https://example.com/scene",
"license": "CC-BY-4.0",
"author": "OpenDance",
"status": "published",
})
eq(scene.id, 7, "场景 id 解析")
eq(scene.name, "舞台", "场景名解析")
is_true(scene.is_usable(), "有 glbUrl 时可用")
almost(scene.config.physics.gravity.y, -3.0, 1e-6, "场景自带重力被解析")
almost(scene.config.physics.ground.friction, 0.6, 1e-6, "未给出的字段用默认值补全")
is_true(scene.source.is_complete(), "来源五元组齐备")
eq(scene.size_label(), "2 KB", "体积标签(<1MB 用 KB)")
var broken: CatalogTypes.Scene = CatalogTypes.Scene.from_dict({"id": 8, "name": "缺文件"})
is_false(broken.is_usable(), "缺 glbUrl 时不可用")
not_null(broken.config, "缺 config 时仍给出默认配置(不返回 null)")
almost(broken.config.physics.gravity.y, -9.8, 1e-6, "缺 config 时重力为默认值")
func test_catalog_bundle_collects_scenes() -> void:
var bundle: CatalogTypes.CatalogBundle = CatalogTypes.CatalogBundle.from_dict({
"songs": [], "motions": [], "models": [],
"scenes": [
{"id": 1, "name": "无 GLB 场景"},
{"id": 2, "name": "可用场景", "glbUrl": "/static/scenes/2.glb"},
],
"contractVersion": {"data-models": "0.7.0", "rest-api": "0.5.0"},
})
eq(bundle.scenes.size(), 2, "目录含 2 个场景")
eq(bundle.find_scene(2).name, "可用场景", "find_scene 按 id 命中")
is_null(bundle.find_scene(99), "find_scene 未命中返回 null")
eq(bundle.default_scene().id, 2, "默认场景跳过缺少 GLB 的场景")
func test_scene_runtime_forces_are_pure_functions() -> void:
var cfg: CatalogTypes.SceneConfig = CatalogTypes.SceneConfig.from_dict({
"physics": {
"gravity": {"x": 0.0, "y": -12.0, "z": 0.0},
"wind": {"base": {"x": 3.0, "y": 0.0, "z": 0.0}, "gustAmplitude": 0.0},
},
})
var runtime: SceneRuntime = SceneRuntime.create_empty(cfg, "测试场景")
almost(runtime.gravity_magnitude(), 12.0, 1e-5, "重力大小 = 12 m/s²")
almost(runtime.wind_force_at(1.0).x, 3.0 * SceneRuntime.WIND_FORCE_SCALE, 1e-4, "风力 = 风速 × 换算系数")
var body := RigidBody3D.new()
body.mass = 2.5
var gravity_force: Vector3 = runtime.gravity_force_for(body)
almost(gravity_force.y, -12.0 * 2.5, 1e-4, "刚体受到的重力 = 重力 × 质量")
body.free()
contains(runtime.describe(), "重力 12.0", "摘要含重力数值(日志可核对)")
contains(runtime.describe(), "3.0,0.0,0.0", "摘要含基础风向量")
almost(runtime.config.physics.ground.friction, 0.6, 1e-6, "空场景使用默认摩擦")
eq(runtime.collider_count, 1, "空场景生成 1 个兜底地面")
is_true(runtime.used_fallback_ground, "标记使用了兜底地面")
is_true(runtime.warnings.size() > 0, "空场景给出降级提示")
runtime.free()
func test_scene_runtime_respects_none_colliders() -> void:
var cfg: CatalogTypes.SceneConfig = CatalogTypes.SceneConfig.from_dict({
"physics": {"colliders": "none"},
})
var runtime: SceneRuntime = SceneRuntime.create_empty(cfg, "无碰撞场景")
eq(runtime.config.physics.colliders, "none", "colliders 解析为 none")
eq(runtime.collider_count, 0, "none 时不生成任何碰撞体")
is_false(runtime.used_fallback_ground, "none 时不兜底地面(尊重场景作者意图)")
runtime.free()
func test_scene_runtime_falls_back_when_file_missing() -> void:
var cfg: CatalogTypes.SceneConfig = CatalogTypes.SceneConfig.from_dict({})
var runtime: SceneRuntime = SceneRuntime.load_from_file("user://不存在的场景.glb", cfg, "缺失场景")
eq(runtime.collider_count, 1, "文件缺失时仍生成兜底地面")
is_true(runtime.used_fallback_ground, "文件缺失时标记兜底")
is_true(runtime.warnings.size() > 0, "文件缺失时记录降级原因")
contains(runtime.warnings_text(), "回退到内置空场景", "降级原因说明会回退(不阻塞开始跟练)")
almost(runtime.config.physics.gravity.y, -9.8, 1e-6, "文件缺失不影响物理参数")
runtime.free()
func test_spawn_transform_and_camera_modes() -> void:
var cfg: CatalogTypes.SceneConfig = CatalogTypes.SceneConfig.from_dict({
"spawnPoints": [{"x": 1.0, "y": 0.0, "z": -2.0, "yaw": 90.0}],
"camera": {"mode": "fixed", "distance": 6.0, "height": 2.0},
})
var runtime: SceneRuntime = SceneRuntime.create_empty(cfg, "机位测试")
var spawn: Transform3D = runtime.spawn_transform(0)
almost(spawn.origin.x, 1.0, 1e-6, "出生点 x = 1")
almost(spawn.origin.z, -2.0, 1e-6, "出生点 z = -2")
# yaw=90° 绕 Y 轴:局部 +Z 轴指向世界 +X
almost(spawn.basis.z.x, 1.0, 1e-5, "出生点 yaw 90° 使 +Z 转向 +X")
almost(runtime.spawn_transform(5).origin.x, 1.0, 1e-6, "越界出生点下标回退第一个")
eq(runtime.config.camera.label(), "固定机位", "相机模式中文标签")
var camera := Camera3D.new()
runtime.apply_camera(camera, Vector3(0, 1, 0), 0.0)
almost(camera.position.z, -2.0 + 6.0, 1e-4, "fixed 机位以出生点为准(距离 6)")
almost(camera.position.y, 0.0 + 2.0, 1e-4, "fixed 机位高度 2")
camera.free()
runtime.free()
func test_environment_mapping_clamps_godot_fog_density() -> void:
var cfg: CatalogTypes.SceneConfig = CatalogTypes.SceneConfig.from_dict({
"environment": {"ambientColor": "#112233", "ambientIntensity": 2.5, "fogDensity": 1.0},
})
var runtime: SceneRuntime = SceneRuntime.create_empty(cfg, "环境测试")
var env := Environment.new()
runtime.apply_environment(env)
is_true(env.fog_enabled, "fogDensity > 0 时开启雾")
almost(env.fog_density, SceneRuntime.FOG_DENSITY_MAX, 1e-6, "fogDensity=1 映射到 Godot 上限")
almost(env.ambient_light_energy, 2.5, 1e-6, "环境光强度写入")
eq(env.ambient_light_color.to_html(false), "112233", "环境光颜色写入")
var off: CatalogTypes.SceneConfig = CatalogTypes.SceneConfig.from_dict({})
var runtime_off: SceneRuntime = SceneRuntime.create_empty(off, "无雾测试")
var env_off := Environment.new()
runtime_off.apply_environment(env_off)
is_false(env_off.fog_enabled, "fogDensity = 0 时关闭雾")
runtime.free()
runtime_off.free()
+1
View File
@@ -0,0 +1 @@
uid://dnr2dotk2b3lq