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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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。"
|
||||
|
||||
@@ -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
@@ -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
@@ -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/ # 当前版本契约快照
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://dnpayvixv5cgc
|
||||
+62
-5
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://ctm7k5ax7nrha
|
||||
@@ -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` 核对 |
|
||||
@@ -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` 机位、雾浓度映射区间。
|
||||
@@ -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 两个资产。
|
||||
```
|
||||
@@ -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, "开源声明条目仍齐备")
|
||||
@@ -0,0 +1 @@
|
||||
uid://ctlu5y6aqcngp
|
||||
+17
-5
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://dnr2dotk2b3lq
|
||||
Reference in New Issue
Block a user