diff --git a/DSH.md b/DSH.md index b4dee0f..480a149 100644 --- a/DSH.md +++ b/DSH.md @@ -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 diff --git a/README.md b/README.md index e393cde..b00c0c1 100644 --- a/README.md +++ b/README.md @@ -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:////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`。 ## 第三方开源声明 diff --git a/README.txt b/README.txt index bde6ab8..69bd7db 100644 --- a/README.txt +++ b/README.txt @@ -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 三处保持一致。 diff --git a/THIRD_PARTY.md b/THIRD_PARTY.md index 82debc9..408b48a 100644 --- a/THIRD_PARTY.md +++ b/THIRD_PARTY.md @@ -1,6 +1,6 @@ # 第三方开源项目声明 -**OpenDance Studio Windows 客户端 v0.1.0** +**OpenDance Studio Windows 客户端 v0.2.0** 本软件使用了以下开源项目,特此声明。完整锚点见 `contracts/open-source-credits.md` (三端共用,任一端新增依赖须回到 `opendance-web` 更新该表)。 diff --git a/build/package-release.sh b/build/package-release.sh index f67e442..e3a99ee 100755 --- a/build/package-release.sh +++ b/build/package-release.sh @@ -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。" diff --git a/contracts/rest-api.yaml b/contracts/rest-api.yaml index 6964940..2170b51 100644 --- a/contracts/rest-api.yaml +++ b/contracts/rest-api.yaml @@ -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 } diff --git a/project.godot b/project.godot index 3f64df1..6740171 100644 --- a/project.godot +++ b/project.godot @@ -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" diff --git a/project.yaml b/project.yaml index 3bda3ba..199b9b6 100644 --- a/project.yaml +++ b/project.yaml @@ -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/ # 当前版本契约快照 diff --git a/src/autoload/catalog_store.gd b/src/autoload/catalog_store.gd index c82dc59..94700f6 100644 --- a/src/autoload/catalog_store.gd +++ b/src/autoload/catalog_store.gd @@ -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/-.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() diff --git a/src/net/catalog_types.gd b/src/net/catalog_types.gd index 4bfc0c6..2f9e4de 100644 --- a/src/net/catalog_types.gd +++ b/src/net/catalog_types.gd @@ -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), + ) diff --git a/src/scene/scene_runtime.gd b/src/scene/scene_runtime.gd new file mode 100644 index 0000000..f286396 --- /dev/null +++ b/src/scene/scene_runtime.gd @@ -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 diff --git a/src/scene/scene_runtime.gd.uid b/src/scene/scene_runtime.gd.uid new file mode 100644 index 0000000..a2f24ee --- /dev/null +++ b/src/scene/scene_runtime.gd.uid @@ -0,0 +1 @@ +uid://dnpayvixv5cgc diff --git a/src/ui/about_screen.gd b/src/ui/about_screen.gd index 4ffe3a3..6bf8ff1 100644 --- a/src/ui/about_screen.gd +++ b/src/ui/about_screen.gd @@ -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) diff --git a/src/ui/credits.gd b/src/ui/credits.gd index b06ec25..42e8915 100644 --- a/src/ui/credits.gd +++ b/src/ui/credits.gd @@ -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] diff --git a/src/ui/game_screen.gd b/src/ui/game_screen.gd index 3da46a3..ed9b527 100644 --- a/src/ui/game_screen.gd +++ b/src/ui/game_screen.gd @@ -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() diff --git a/src/ui/main.gd b/src/ui/main.gd index 76f8af5..3702757 100644 --- a/src/ui/main.gd +++ b/src/ui/main.gd @@ -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: diff --git a/src/ui/main_menu.gd b/src/ui/main_menu.gd index fd76980..2445a27 100644 --- a/src/ui/main_menu.gd +++ b/src/ui/main_menu.gd @@ -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: diff --git a/src/ui/scene_panel.gd b/src/ui/scene_panel.gd new file mode 100644 index 0000000..b832d24 --- /dev/null +++ b/src/ui/scene_panel.gd @@ -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 diff --git a/src/ui/scene_panel.gd.uid b/src/ui/scene_panel.gd.uid new file mode 100644 index 0000000..e83bb02 --- /dev/null +++ b/src/ui/scene_panel.gd.uid @@ -0,0 +1 @@ +uid://ctm7k5ax7nrha diff --git a/tasks/012-about-version.md b/tasks/012-about-version.md new file mode 100644 index 0000000..7814efd --- /dev/null +++ b/tasks/012-about-version.md @@ -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` 核对 | diff --git a/tasks/013-scene-selection.md b/tasks/013-scene-selection.md new file mode 100644 index 0000000..849c84e --- /dev/null +++ b/tasks/013-scene-selection.md @@ -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` 机位、雾浓度映射区间。 diff --git a/tasks/014-full-package.md b/tasks/014-full-package.md new file mode 100644 index 0000000..635b192 --- /dev/null +++ b/tasks/014-full-package.md @@ -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 两个资产。 +``` diff --git a/tests/test_about.gd b/tests/test_about.gd new file mode 100644 index 0000000..8b31de8 --- /dev/null +++ b/tests/test_about.gd @@ -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, "开源声明条目仍齐备") diff --git a/tests/test_about.gd.uid b/tests/test_about.gd.uid new file mode 100644 index 0000000..1b1a335 --- /dev/null +++ b/tests/test_about.gd.uid @@ -0,0 +1 @@ +uid://ctlu5y6aqcngp diff --git a/tests/test_credits.gd b/tests/test_credits.gd index 315740c..c724949 100644 --- a/tests/test_credits.gd +++ b/tests/test_credits.gd @@ -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) diff --git a/tests/test_scene_config.gd b/tests/test_scene_config.gd new file mode 100644 index 0000000..8b71985 --- /dev/null +++ b/tests/test_scene_config.gd @@ -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() diff --git a/tests/test_scene_config.gd.uid b/tests/test_scene_config.gd.uid new file mode 100644 index 0000000..ca7ba54 --- /dev/null +++ b/tests/test_scene_config.gd.uid @@ -0,0 +1 @@ +uid://dnr2dotk2b3lq