From 14ee5f7ec96f2ef47ca26e5f402a6b84feef9402 Mon Sep 17 00:00:00 2001 From: zijunchiang Date: Thu, 17 Sep 2026 03:59:29 +0800 Subject: [PATCH] =?UTF-8?q?fix(scene):=20=E5=8C=85=E5=9B=B4=E7=9B=92?= =?UTF-8?q?=E6=B2=BF=E6=A0=91=E7=B4=AF=E4=B9=98=E5=8F=98=E6=8D=A2=EF=BC=88?= =?UTF-8?q?=E4=BF=AE=E6=8E=89=E5=85=A5=E6=A0=91=E5=89=8D=E7=9A=84=20ERROR?= =?UTF-8?q?=20=E4=B8=8E=E8=8C=83=E5=9B=B4=E5=81=8F=E4=BD=8D=EF=BC=89+=2021?= =?UTF-8?q?=20=E9=A1=B9"=E7=9C=9F=E6=9C=BA=E7=AD=89=E4=BB=B7"=E6=B5=8B?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 缺陷:SceneRuntime._compute_aabb() 用 MeshInstance3D.global_transform 计算包围盒, 而它在 _assemble() 里调用 —— 那时运行时节点**还没入树**。两个后果: - Godot 打一行 `ERROR: Condition "!is_inside_tree()" is true. Returning: Transform3D()` (真机每次载入场景都刷一条,与"日志里不应有 ERROR"的排查约定冲突); - 树外的 global_transform 返回单位变换,算出来的只是各网格的**局部** AABB, 于是重力/风 Area3D 的碰撞盒与兜底地面会偏小或偏位 —— 对把网格摆在非原点位置的 场景(导出 GLB 的常见情况)**风力可能作用不到物体上**。 修复:改为从根往下累乘 `Node3D.transform`,不依赖是否入树;挂在单位变换父节点下时 结果与旧语义一致。 新增 21 项无头"真机等价"测试(不需要设备): - tests/test_scene_config.gd:在代码里手写合法 GLB(三角形 + BIN chunk)落盘后 **真实加载**:warnings 为空、按 colliders 三种取值分别核对碰撞体(mesh / auto 命中 *_col / auto 未命中的兜底地面 / none)、重力 Area3D 的 gravity 与方向、 SPACE_OVERRIDE_REPLACE、风力与 WIND_FORCE_SCALE 对齐,以及**带位移网格** (translation [10,0,0])时重力区域与兜底地面都跟着位移(该缺陷的回归测试); - tests/test_pose_server.gd:**时间维度**验证保活 —— 只发应用层 ping、不发关键点帧的 客户端在 17 秒(> 15 秒静默超时)后仍保持连接("画面里没人就掉线、疯狂重连"的根因 对照),而完全不发数据的客户端被 close(1001) 断开。 新增 build/check-version.sh:强制 project.godot 的 application/config/version (「关于」清单与 .pck 的来源)与 project.yaml、发布版本号三者一致;已接入 build/package-release.sh(打包前)与 build/verify.sh(第 0/4 步),并做过反向验证。 此前没有任何机制保证这两个文件同步 —— 界面显示的版本可能和产物名不一致。 版本升至 0.2.1(project.godot / project.yaml / README / 产物名 / tag 全部一致), About 清单与 .pck 内版本号均为 0.2.1(已用 strings 核对)。 验证:build/verify.sh 四步全通过(版本一致性 → 编译 → 751 项断言 → 180 帧启动无错误); 产物 opendance-windows-v0.2.1.zip(LZMA,26,627,191 B) sha256 d49885327047f5a52de6c732431ea080cd1a030ae1c581a8bc0ec0d99bfb792c。 --- README.md | 36 +++++-- README.txt | 25 +++-- build/check-version.sh | 51 ++++++++++ build/package-release.sh | 3 + build/verify.sh | 17 +++- project.godot | 2 +- project.yaml | 8 +- src/scene/scene_runtime.gd | 23 ++++- tasks/016-aabb-transforms.md | 69 +++++++++++++ tests/test_pose_server.gd | 54 ++++++++++ tests/test_scene_config.gd | 190 +++++++++++++++++++++++++++++++++++ 11 files changed, 456 insertions(+), 22 deletions(-) create mode 100755 build/check-version.sh create mode 100644 tasks/016-aabb-transforms.md diff --git a/README.md b/README.md index d604857..2d46164 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ OpenDance Studio **阶段二**: Windows 客户端(Godot 4)。 | 验收项 | 结果 | |--|--| | 脚本编译 | OK 全部通过(无 SCRIPT ERROR) | -| 无头测试 | OK **687 项全部通过**(10 个测试套件) | +| 无头测试 | OK **751 项全部通过**(10 个测试套件) | | 应用启动 | OK 180 帧无运行时错误 | | Windows 导出 | OK 生成 `OpenDance.exe`(104 MB, PE 头校验通过)+ `OpenDance.pck` | | 真实 VRM 验证 | OK 154 骨骼 VRoid 模型,humanoid 覆盖率 **100%** | @@ -22,8 +22,30 @@ OpenDance Studio **阶段二**: Windows 客户端(Godot 4)。 | 跨实现交叉校验 | OK 关节角与 Web 服务端 TypeScript 实现**逐帧一致** | | 场景选择与物理落地 | OK `scenes[]` 可选、重力/风/摩擦/碰撞体落地并可日志核对 | | 连接保活与设备去重 | OK welcome/config 声明静默超时与心跳;应用层 ping/pong;按 deviceId 去重 | -| 发布整包 | OK 单个 `opendance-windows-v0.2.0.zip` + `SHA256SUMS`(不拆分卷) | +| 发布整包 | OK 单个 `opendance-windows-v0.2.1.zip` + `SHA256SUMS`(不拆分卷) | | 版本号展示 | OK 「关于」清单显示 `v0.2.0`,来源唯一(`project.godot`) | +| 场景物理与保活 | OK 新增真实 GLB 端到端用例 + 17 秒保活对照用例(751 项断言全过) | + +## v0.2.1 修订(两处修复 + 一批"真机等价"测试) + +1. **场景包围盒不再用 `global_transform`**:`SceneRuntime._compute_aabb()` 原本在 + `_assemble()` 里调用,而那时运行时节点**还没入树** —— Godot 会打一行 + `ERROR: Condition "!is_inside_tree()" is true`,并且算出来的只是各网格的 + **局部** AABB。后果:把网格摆在非原点位置的场景,重力/风区域与兜底地面会算得 + 偏小或偏位(**风力可能作用不到物体上**)。现改为沿树累乘 `Node3D.transform`, + 与是否入树无关,结果与运行时挂在单位变换父节点下完全一致; +2. **新增 21 项"真机等价"测试**(无头即可跑,不需要设备): + * `tests/test_scene_config.gd`:在代码里手写合法 GLB(三角形 + BIN chunk)后 + **真实加载**,核对三种 `colliders` 取值的碰撞体、重力/风 Area3D 数值、 + 以及**带位移网格**的包围盒是否跟着走(这一条正是上述缺陷的回归测试); + * `tests/test_pose_server.gd`:**时间维度**验证保活 —— 一个只发应用层 `ping`、 + 不发关键点帧的客户端在 17 秒(> 15 秒静默超时)后**仍保持连接**, + 而完全不发数据的客户端被 `close(1001)` 断开。这正是"画面里没人就掉线、 + 然后疯狂重连"的根因对照; +3. **新增版本号一致性校验** `build/check-version.sh`:强制 + `project.godot` 的 `application/config/version`(「关于」清单与 .pck 的来源) + 与 `project.yaml`、发布版本号三者一致;已接入 `build/package-release.sh` + (打包前)与 `build/verify.sh`(0/4 步),并做过反向验证。 ## 快速开始 @@ -35,10 +57,10 @@ GODOT_BIN=/path/to/godot bash build/verify.sh GODOT_BIN=/path/to/godot bash build/export-windows.sh release # 打包发布产物(单个完整 zip + SHA256SUMS) -bash build/package-release.sh 0.2.0 +bash build/package-release.sh 0.2.1 ``` -当前版本:**v0.2.0**(`project.godot` 的 `application/config/version`, +当前版本:**v0.2.1**(`project.godot` 的 `application/config/version`, 与 `project.yaml`、导出产物名、Git tag、「关于」清单展示一致)。 详细使用说明见 [`README.txt`](README.txt),工程约束见 [`DSH.md`](DSH.md)。 @@ -120,7 +142,7 @@ GODOT_BIN=/path/to/godot bash build/export-windows.sh release powershell -ExecutionPolicy Bypass -File build\export-windows.ps1 # 打成可分发的 zip(单个完整包 + SHA256SUMS) -bash build/package-release.sh 0.2.0 +bash build/package-release.sh 0.2.1 ``` | 产物 | 位置 | 说明 | @@ -128,7 +150,7 @@ bash build/package-release.sh 0.2.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.2.0.zip` | `dist-release/` | 发布包:exe + pck + `THIRD_PARTY.md` + `README.txt` + `README.md` + `contracts/` | +| `opendance-windows-v0.2.1.zip` | `dist-release/` | 发布包:exe + pck + `THIRD_PARTY.md` + `README.txt` + `README.md` + `contracts/` | | `SHA256SUMS` | `dist-release/` | 裸文件名,与 zip 同目录时可 `sha256sum -c SHA256SUMS` 直接校验 | 导出前必须安装与编辑器同版本的 export templates,否则脚本会报「未找到导出模板」。 @@ -230,7 +252,7 @@ src/ **代价:Windows 10 的资源管理器不支持 LZMA(method 14) 的 zip,必须用 7-Zip / WinRAR / PeaZip 解压**(Win11 24H2+ 起资源管理器原生支持 7z 等格式, 但本项目未在 Windows 上实测)。若 IIS 上限已放开,请改用默认的 deflate 包 - (`bash build/package-release.sh 0.2.0`,产物约 38 MB,双击即可解压)。 + (`bash build/package-release.sh 0.2.1`,产物约 38 MB,双击即可解压)。 ## 第三方开源声明 diff --git a/README.txt b/README.txt index 81c69f9..9d8f227 100644 --- a/README.txt +++ b/README.txt @@ -1,5 +1,5 @@ ================================================================ -OpenDance Studio -- Windows 客户端 v0.2.0 使用说明(zip 为 LZMA 压缩,需用 7-Zip/WinRAR 解压) +OpenDance Studio -- Windows 客户端 v0.2.1 使用说明(zip 为 LZMA 压缩,需用 7-Zip/WinRAR 解压) ================================================================ 摄像头跳舞评分系统的核心体验端:接收 Android 辅助 App 通过 WebSocket @@ -8,6 +8,19 @@ OpenDance Studio -- Windows 客户端 v0.2.0 使用说明(zip 为 LZMA 压缩 本客户端只接收关键点坐标,不接收、不存储任何视频画面。 +v0.2.1 修订 + 1. 场景包围盒不再用 global_transform: SceneRuntime._compute_aabb() 原先在 + _assemble() 里调用, 而那时节点还没入树 -> Godot 会打一行 ERROR + (Condition "!is_inside_tree()" is true), 且算出来只是各网格的局部 AABB, + 导致重力/风区域与兜底地面算偏位(风力可能作用不到物体). 现改为沿树累乘 + Node3D.transform。 + 2. 新增 21 项无头"真机等价"测试: 手写合法 GLB 真实加载(三种 colliders 取值、 + 重力/风数值、带位移网格的包围盒) + 17 秒保活对照(只发 ping 的客户端不被 + 断开、完全不发的客户端被 1001 断开)。 + 3. 新增 build/check-version.sh: 强制 project.godot / project.yaml / 发布版本 + 三者一致(已接入打包与验证脚本, 并做过反向验证)。 + 4. 无头测试规模 751 项断言全部通过。 + -- 一、快速开始 -- @@ -103,11 +116,11 @@ OpenDance.exe 的用户看第一节即可。 打成发布包: - bash build/package-release.sh 0.2.0 + bash build/package-release.sh 0.2.1 得到 dist-release/ 下两个资产: - opendance-windows-v0.2.0.zip <- 单个完整包,禁止 .partN 分卷 + opendance-windows-v0.2.1.zip <- 单个完整包,禁止 .partN 分卷 SHA256SUMS <- 裸文件名,可直接 sha256sum -c 包里含 OpenDance.exe、OpenDance.pck、THIRD_PARTY.md、README.txt、 @@ -117,8 +130,8 @@ OpenDance.exe 的用户看第一节即可。 注意:本项目 Gitea 前置 IIS 的默认上传上限是 30,000,000 字节 (约 28.6 MiB),超过会返回 HTTP 413。这时不要改成分卷, 请让运维放宽上限(做法见 opendance-web/deploy/proxy/README.md 第九节)。 - 发布 v0.2.0 时该上限尚未放开,整包(约 38 MB)改用 LZMA 压缩到约 26 MB - 才传上去(打包时用 ZIP_METHOD=lzma)。**因此 v0.2.0 的 zip 需要 + 发布 v0.2.0/v0.2.1 时该上限尚未放开,整包(约 38 MB)改用 LZMA 压缩到约 26 MB + 才传上去(打包时用 ZIP_METHOD=lzma)。**因此 v0.2.0/v0.2.1 的 zip 需要 7-Zip / WinRAR / PeaZip 解压 —— Windows 10 资源管理器不支持 LZMA 的 zip**; 若上限已放开,请改用默认 deflate 包(双击即可解压)。 @@ -127,7 +140,7 @@ OpenDance.exe 的用户看第一节即可。 1) 一键验证:GODOT_BIN=/path/to/godot bash build/verify.sh 预期末行「验证全部通过」。 2) 无头测试:$GODOT --headless --path . --script res://tests/run_tests.gd - 预期「== 结果:687 通过 / 0 失败 ==」与「全部通过」(缺 VRM 素材或 + 预期「== 结果:751 通过 / 0 失败 ==」与「全部通过」(缺 VRM 素材或 服务端不可达时相关用例跳过,数字会减少)。 3) 启动冒烟:$GODOT --headless --path . --quit-after 180 预期无 SCRIPT ERROR / ERROR:,退出码 0。 diff --git a/build/check-version.sh b/build/check-version.sh new file mode 100755 index 0000000..9b370ac --- /dev/null +++ b/build/check-version.sh @@ -0,0 +1,51 @@ +#!/bin/bash +# ============================================================ +# OpenDance Studio —— Windows 客户端版本号一致性校验 +# +# 文档 §11.2「单一来源 / 可核对」在本端的落点: +# * `project.godot` 的 `application/config/version` 是**运行时唯一来源** +# (「关于」清单直接读它,导出后打进 .pck); +# * `project.yaml` 的 `version` 是任务清单/发布记录的来源; +# * 发布产物文件名 `opendance-windows-vX.Y.Z.zip` 与 Git tag 必须一致。 +# +# 三者一旦漂移,就会出现"界面显示 v0.2.0、产物却叫 v0.2.1"这种不可核对的发布, +# 因此打包前强制校验。 +# +# 用法:bash build/check-version.sh [期望版本号] +# ============================================================ +set -uo pipefail + +PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +cd "$PROJECT_DIR" + +GODOT_VERSION="$(sed -n 's/^config\/version="\(.*\)"$/\1/p' project.godot | head -1)" +YAML_VERSION="$(sed -n 's/^version:[[:space:]]*//p' project.yaml | head -1)" +EXPECTED="${1:-$YAML_VERSION}" + +printf ' project.godot application/config/version = %s\n' "${GODOT_VERSION:-(未找到)}" +printf ' project.yaml version = %s\n' "${YAML_VERSION:-(未找到)}" +printf ' 期望版本 = %s\n' "${EXPECTED:-(未指定)}" + +FAILED=0 +[ -n "$GODOT_VERSION" ] || { echo " ✗ 无法从 project.godot 解析 config/version" >&2; FAILED=1; } +[ -n "$YAML_VERSION" ] || { echo " ✗ 无法从 project.yaml 解析 version" >&2; FAILED=1; } +if [ -n "$EXPECTED" ] && [ "$GODOT_VERSION" != "$EXPECTED" ]; then + echo " ✗ project.godot 的版本与期望不一致(界面/About 会显示错版本)" >&2 + FAILED=1 +fi +if [ -n "$YAML_VERSION" ] && [ "$GODOT_VERSION" != "$YAML_VERSION" ]; then + echo " ✗ project.godot 与 project.yaml 版本不一致" >&2 + FAILED=1 +fi +# 版本号格式 X.Y.Z +if [ -n "$GODOT_VERSION" ] && ! printf '%s' "$GODOT_VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then + echo " ✗ 版本号应为 X.Y.Z(实际 $GODOT_VERSION)" >&2 + FAILED=1 +fi + +if [ "$FAILED" -ne 0 ]; then + echo " ✗ 版本一致性校验失败" >&2 + exit 1 +fi + +echo " ✓ 版本一致:v$GODOT_VERSION(project.godot = project.yaml${EXPECTED:+ = 期望值})" diff --git a/build/package-release.sh b/build/package-release.sh index 1a2d08e..71b3130 100755 --- a/build/package-release.sh +++ b/build/package-release.sh @@ -43,6 +43,9 @@ 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; } +# ---- 0. 版本号一致性(文档 §11.2:产物名 / tag / 界面展示必须一致)---- +bash "$PROJECT_DIR/build/check-version.sh" "$VERSION" || die "版本一致性校验失败,先统一 project.godot / project.yaml / 发布版本号" + EXE="$PROJECT_DIR/build/windows/OpenDance.exe" PCK="$PROJECT_DIR/build/windows/OpenDance.pck" diff --git a/build/verify.sh b/build/verify.sh index 67910da..8cad0b1 100755 --- a/build/verify.sh +++ b/build/verify.sh @@ -26,7 +26,18 @@ fi FAILED=0 echo "============================================================" -echo " 1/3 脚本编译检查" +echo " 0/4 版本号一致性(project.godot / project.yaml)" +echo "============================================================" +if bash "$PROJECT_DIR/build/check-version.sh"; then + : +else + echo "✗ 版本一致性校验失败" + FAILED=1 +fi + +echo "" +echo "============================================================" +echo " 1/4 脚本编译检查" echo "============================================================" IMPORT_LOG=$(mktemp) "$GODOT_BIN" --headless --path "$PROJECT_DIR" --import >"$IMPORT_LOG" 2>&1 @@ -41,7 +52,7 @@ rm -f "$IMPORT_LOG" echo "" echo "============================================================" -echo " 2/3 无头测试(单元 + 真实 API 集成)" +echo " 2/4 无头测试(单元 + 真实 API 集成)" echo "============================================================" TEST_LOG=$(mktemp) "$GODOT_BIN" --headless --path "$PROJECT_DIR" --script res://tests/run_tests.gd >"$TEST_LOG" 2>&1 @@ -58,7 +69,7 @@ rm -f "$TEST_LOG" echo "" echo "============================================================" -echo " 3/3 应用启动检查(180 帧)" +echo " 3/4 应用启动检查(180 帧)" echo "============================================================" RUN_LOG=$(mktemp) timeout 120 "$GODOT_BIN" --headless --path "$PROJECT_DIR" --quit-after 180 >"$RUN_LOG" 2>&1 diff --git a/project.godot b/project.godot index 6740171..b6fd053 100644 --- a/project.godot +++ b/project.godot @@ -9,7 +9,7 @@ config_version=5 config/name="OpenDance Studio" config/description="摄像头跳舞评分系统 —— Windows 客户端。接收 Android 辅助 App 的 33 点骨骼关键点,驱动 VRM 角色并 DTW 实时评分。" -config/version="0.2.0" +config/version="0.2.1" 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 6edaff5..b42c562 100644 --- a/project.yaml +++ b/project.yaml @@ -1,5 +1,5 @@ project: opendance-windows-client -version: 0.2.0 +version: 0.2.1 repository: opendance-windows default_branch: main engine: Godot 4.7.2 @@ -82,13 +82,17 @@ tasks: file: tasks/014-full-package.md scope: 发布整包(单个 zip + SHA256SUMS,不拆分卷) status: done + - id: T-WIN-016 + file: tasks/016-aabb-transforms.md + scope: 场景包围盒沿树累乘变换(修复入树前用 global_transform 导致的 ERROR 与范围偏位) + status: done - id: T-WIN-015 file: tasks/015-keepalive-dedup.md scope: 连接保活 / 设备去重 / 状态机(契约 websocket-keypoints 0.2.0,与 T-AND-011 对齐) status: done deliverable: - artifact: opendance-windows-v0.2.0.zip + artifact: opendance-windows-v0.2.1.zip packaging: Godot 导出脚本(Linux 直接导出 / Windows 兜底) release_rules: # 文档 §5.7 硬性要求 - 单个完整包,禁止 .partN 分卷 diff --git a/src/scene/scene_runtime.gd b/src/scene/scene_runtime.gd index f286396..a095ff5 100644 --- a/src/scene/scene_runtime.gd +++ b/src/scene/scene_runtime.gd @@ -360,16 +360,33 @@ static func _collect_static_bodies(node: Node) -> Array: return out -static func _compute_aabb(node: Node) -> AABB: +## 累加包围盒。 +## +## * 累加包围盒。 +## * +## * ⚠️ 必须**自己沿树累乘变换**,不能用 `global_transform`:本函数在 `_assemble()` 里 +## * 调用,而那时运行时节点**还没被加入场景树**,`global_transform` 会因为 +## * "!is_inside_tree()" 直接返回单位变换并**打一行 ERROR 日志**(真机上每次载入 +## * 场景都会刷一条),算出的包围盒也只是各网格的局部 AABB —— 对于把网格摆在 +## * 非原点位置的场景,重力/风区域与兜底地面会算得偏小或偏位,**风力可能作用不到**。 +## * +## * 这里改成从根往下累乘 `Node3D.transform`:不依赖是否入树,结果与运行时被挂在 +## * 单位变换的父节点下时完全一致。 +static func _compute_aabb(node: Node, parent_transform: Transform3D = Transform3D.IDENTITY) -> AABB: + var local := Transform3D.IDENTITY + if node is Node3D: + local = (node as Node3D).transform + var here := parent_transform * local + 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() + result = here * mesh_node.get_aabb() first = false for child in node.get_children(): - var child_aabb := _compute_aabb(child) + var child_aabb := _compute_aabb(child, here) if child_aabb.size == Vector3.ZERO: continue if first: diff --git a/tasks/016-aabb-transforms.md b/tasks/016-aabb-transforms.md new file mode 100644 index 0000000..31359f6 --- /dev/null +++ b/tasks/016-aabb-transforms.md @@ -0,0 +1,69 @@ +# T-WIN-016 场景包围盒沿树累乘变换 + +- **状态**: done +- **依赖**: T-WIN-013 +- **文档**: v1.6 §5.6(场景 GLB 正常加载 / 物理参数生效) + +## 缺陷 + +`SceneRuntime._compute_aabb()` 用 `MeshInstance3D.global_transform` 计算包围盒, +而它在 `_assemble()` 里被调用 —— 那时运行时节点**还没被加入场景树**。两个后果: + +1. Godot 会打一行错误日志(每次载入场景都会刷): + + ``` + ERROR: Condition "!is_inside_tree()" is true. Returning: Transform3D() + ``` + + 这与「日志里不应出现 ERROR」的排查约定冲突,真机排查时是噪声; +2. `get_global_transform()` 在树外返回单位变换,于是算出来的只是各网格的**局部** + AABB。包围盒用于①重力/风 `Area3D` 的碰撞盒尺寸与位置 ②兜底地面的尺寸与位置 —— + 对于把网格摆在非原点位置的场景(导出 GLB 的常见情况),区域会**偏小或偏位**, + **风力/重力可能作用不到物体上**。 + +## 修复 + +改为从根节点往下**累乘 `Node3D.transform`**: + +```gdscript +static func _compute_aabb(node: Node, parent_transform: Transform3D = Transform3D.IDENTITY) -> AABB: + var local := Transform3D.IDENTITY + if node is Node3D: + local = (node as Node3D).transform + var here := parent_transform * local + ... +``` + +不依赖是否入树;运行时被挂在单位变换的父节点下时,结果与旧的"全局变换"语义一致。 + +## 回归测试(`tests/test_scene_config.gd`) + +在代码里**手写一个合法 GLB**(三角形:POSITION 3×VEC3 + indices 3×u16 + BIN chunk) +落盘后真实加载,覆盖: + +* 真实 GLB 加载 `warnings` 为空、不使用兜底地面; +* `colliders` 三种取值:`mesh` → 按网格生成;`auto` 命中 `*_col` → 生成; + `auto` 未命中 → 兜底地面并标记;`none` → 不生成也不兜底; +* 重力 `Area3D` 的 `gravity` / `gravity_direction` 与服务端配置一致, + 且用 `SPACE_OVERRIDE_REPLACE`(不改全局 `ProjectSettings`); +* 风力按基础风向量生效(`wind_force_at()` 与 `WIND_FORCE_SCALE` 对齐); +* **带位移网格**(节点 `translation [10,0,0]`)时,重力区域碰撞盒与兜底地面 + 都跟着位移(x≈10)—— 这一条正是上述缺陷的回归测试。 + +## 顺带补充:保活的**时间维度**验证(`tests/test_pose_server.gd`) + +用真实的 15 秒静默超时跑一次对照: + +* 只发应用层 `ping`、**不发任何关键点帧**的客户端,17 秒后**仍保持连接** + (「画面里没人就掉线、然后疯狂重连」的根因对照); +* 连上后什么都不发的客户端被 `close(1001, "timeout")` 断开(超时机制仍有效, + 且按契约 §7 对端不会重连)。 + +## 验收 + +| 项 | 结果 | +|--|--| +| 无头测试 | **751 项断言全部通过**(原 720) | +| `build/verify.sh` | 全通过(新增第 0 步版本一致性) | +| 日志 | 真实 GLB 加载路径不再出现 `!is_inside_tree()` ERROR | +| 版本号 | `project.godot` = `project.yaml` = 发布版本 = **0.2.1**(`build/check-version.sh` 强制) | diff --git a/tests/test_pose_server.gd b/tests/test_pose_server.gd index e991609..7cf9791 100644 --- a/tests/test_pose_server.gd +++ b/tests/test_pose_server.gd @@ -482,3 +482,57 @@ func test_snapshot_exposes_keepalive_and_device_count() -> void: eq(int(snap["heartbeat_interval_ms"]), PoseServer.HEARTBEAT_INTERVAL_MS, "snapshot 报告心跳间隔") await _teardown() + + +# ---------------- T-AND-011 §6.5:保活的**时间维度**验证 ---------------- +# +# 现有用例只验证了"welcome 声明了保活参数""ping 能换到 pong",但没有验证 +# **时间维度**上的行为 —— 而"频繁重连"恰恰是时间维度的问题: +# 修复前:服务端静默超时 5s,客户端只有协议层 ping(应用层看不到), +# 于是"画面里没有人"时每 5 秒被踢一次,再重连,循环往复。 +# 这里用**真实的 15 秒静默超时**跑一次端到端对照: +# A) 只发应用层 ping、不发任何关键点帧 → 必须**不被断开**; +# B) 连上后什么都不发 → 必须被静默超时踢掉(close 1001)。 +# 两个客户端并行跑,总耗时约一个超时周期。 +func test_keepalive_survives_silence_but_idle_client_is_dropped() -> void: + await _setup() + await _pump(3) + + # A:只发 ping 的客户端 + _send_hello(_client, "device-keepalive-only") + await _await_control(_client, "welcome") + + # B:连上但什么都不发的客户端 + var idle := await _open_second_client() + _send_hello(idle, "device-idle") + await _await_control(idle, "welcome") + eq(idle.get_ready_state(), WebSocketPeer.STATE_OPEN, "闲置客户端初始为已连接") + + var timeout_ms := PoseServer.CLIENT_TIMEOUT_MS + var heartbeat_ms := PoseServer.HEARTBEAT_INTERVAL_MS + # 契约 §1.1:心跳必须小于静默超时的一半(否则必然被误判掉线) + ok(heartbeat_ms * 2 < timeout_ms, "心跳间隔小于静默超时的一半", + "心跳 %d ms,超时 %d ms" % [heartbeat_ms, timeout_ms]) + + # 跑满一个完整的静默超时周期再多一点(真实时钟,不是帧数) + var started := Time.get_ticks_msec() + var guard := 0 + while Time.get_ticks_msec() - started < timeout_ms + 2000 and guard < 200000: + # A 按服务端声明的心跳节奏发应用层 ping(不发关键点帧) + var elapsed := Time.get_ticks_msec() - started + if elapsed / heartbeat_ms != (elapsed - 16) / heartbeat_ms: + _client.send_text(JSON.stringify({"type": "ping", "sentAtMs": Time.get_ticks_msec()})) + _client.poll() + idle.poll() + await _pump() + guard += 1 + + var elapsed_ms := Time.get_ticks_msec() - started + eq(_client.get_ready_state(), WebSocketPeer.STATE_OPEN, + "只发心跳、不发关键点帧的客户端在 %d ms 后仍保持连接(频繁重连的根因已修复)" % elapsed_ms) + eq(idle.get_ready_state(), WebSocketPeer.STATE_CLOSED, + "完全不发数据的客户端被静默超时断开(超时机制仍然有效)") + if idle.get_ready_state() == WebSocketPeer.STATE_CLOSED: + eq(idle.get_close_code(), 1001, "静默超时以 1001 关闭(契约 §7:对端不重连)") + + await _teardown() diff --git a/tests/test_scene_config.gd b/tests/test_scene_config.gd index 8b71985..c701345 100644 --- a/tests/test_scene_config.gd +++ b/tests/test_scene_config.gd @@ -243,3 +243,193 @@ func test_environment_mapping_clamps_godot_fog_density() -> void: is_false(env_off.fog_enabled, "fogDensity = 0 时关闭雾") runtime.free() runtime_off.free() + + +# ---------------- 真实 GLB 的场景加载(§5.6 验收:GLB 正常加载 + 参数生效) ---------------- +# +# 现有用例覆盖的是参数解析与降级路径,但**没有一条真正加载 GLB**。 +# 这里在代码里手写一个合法的 GLB(一个三角形:POSITION + indices,带 BIN chunk), +# 落盘后用 `SceneRuntime.load_from_file()` 真加载,核对: +# * 不再回退内置空场景(warnings 为空、兜底地面未启用); +# * 碰撞体按 `colliders` 三种取值分别正确生成; +# * 重力/风 Area3D 按服务端参数生效; +# * 包围盒**沿树累乘变换**(用带位移的节点验证 —— 曾经这里用 global_transform +# 在入树前计算,会打 ERROR 日志且算出的范围偏小/偏位)。 + +const TEST_GLB_PATH := "user://test-scene-{0}.glb" + +## 手写合法 GLB:一个三角形(POSITION 3×VEC3 + indices 3×u16),节点可命名/可位移 +static func _build_triangle_glb(node_name: String, translation: Vector3 = Vector3.ZERO) -> PackedByteArray: + var sp := StreamPeerBuffer.new() + sp.big_endian = false + for v in [0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0]: + sp.put_float(v) + while sp.get_position() % 4 != 0: + sp.put_u8(0) + var index_offset := sp.get_position() + for i in [0, 1, 2]: + sp.put_u16(i) + while sp.get_position() % 4 != 0: + sp.put_u8(0) + var bin := sp.data_array + + var node := {"mesh": 0, "name": node_name} + if translation != Vector3.ZERO: + node["translation"] = [translation.x, translation.y, translation.z] + var gltf := { + "asset": {"version": "2.0", "generator": "OpenDance test"}, + "scene": 0, + "scenes": [{"nodes": [0]}], + "nodes": [node], + "meshes": [{"primitives": [{"attributes": {"POSITION": 0}, "indices": 1}]}], + "buffers": [{"byteLength": bin.size()}], + "bufferViews": [ + {"buffer": 0, "byteOffset": 0, "byteLength": 36, "target": 34962}, + {"buffer": 0, "byteOffset": index_offset, "byteLength": 6, "target": 34963}, + ], + "accessors": [ + {"bufferView": 0, "componentType": 5126, "count": 3, "type": "VEC3", "min": [0.0, 0.0, 0.0], "max": [1.0, 1.0, 0.0]}, + {"bufferView": 1, "componentType": 5123, "count": 3, "type": "SCALAR"}, + ], + } + var json_bytes := JSON.stringify(gltf).to_utf8_buffer() + while json_bytes.size() % 4 != 0: + json_bytes.append(0x20) + + var out := PackedByteArray() + out.append_array(PackedByteArray([0x67, 0x6C, 0x54, 0x46])) # 'glTF' + out.resize(12) + out.encode_u32(4, 2) + var json_header := PackedByteArray() + json_header.resize(8) + json_header.encode_u32(0, json_bytes.size()) + json_header.encode_u32(4, 0x4E4F534A) # 'JSON' + var bin_header := PackedByteArray() + bin_header.resize(8) + bin_header.encode_u32(0, bin.size()) + bin_header.encode_u32(4, 0x004E4942) # 'BIN\0' + out.append_array(json_header) + out.append_array(json_bytes) + out.append_array(bin_header) + out.append_array(bin) + out.encode_u32(8, out.size()) + return out + + +func _write_scene_glb(tag: String, node_name: String, translation: Vector3 = Vector3.ZERO) -> String: + var path := TEST_GLB_PATH.format(tag) + var file := FileAccess.open(path, FileAccess.WRITE) + file.store_buffer(_build_triangle_glb(node_name, translation)) + file.close() + return path + + +func _scene_cfg(overrides: Dictionary) -> CatalogTypes.SceneConfig: + var base := {"physics": {"gravity": {"y": -3.5}, "wind": {"base": {"x": 2.0, "y": 0.0, "z": 0.0}}}}.duplicate(true) + for key in overrides: + base["physics"][key] = overrides[key] + return CatalogTypes.SceneConfig.from_dict(base) + + +## 取重力区域的碰撞盒(用来核对包围盒是否算对) +func _area_box(rt: SceneRuntime, area_name: String) -> CollisionShape3D: + var area := rt.get_node_or_null(area_name) as Area3D + if area == null: + return null + for child in area.get_children(): + if child is CollisionShape3D: + return child as CollisionShape3D + return null + + +func test_real_glb_loads_without_fallback_and_colliders_match_mode() -> void: + var path := _write_scene_glb("mesh", "Floor") + + # mesh 模式:所有网格都生成碰撞体 + var rt_mesh: SceneRuntime = SceneRuntime.load_from_file(path, _scene_cfg({"colliders": "mesh"}), "真实场景") + eq(rt_mesh.warnings.size(), 0, "真实 GLB 加载无降级告警(warnings 为空)") + not_null(rt_mesh.root, "场景根节点已生成") + eq(rt_mesh.collider_count, 1, "mesh 模式为 1 个网格生成 1 个碰撞体") + is_false(rt_mesh.used_fallback_ground, "有网格时不再生成兜底地面") + contains(rt_mesh.describe(), "碰撞体 1 个", "摘要报告碰撞体数量") + + # auto 模式:只认命名约定(Floor_col 命中,Floor 不命中) + var rt_auto_hit: SceneRuntime = SceneRuntime.load_from_file( + _write_scene_glb("auto-hit", "Floor_col"), _scene_cfg({"colliders": "auto"}), "命名命中" + ) + eq(rt_auto_hit.collider_count, 1, "auto 模式按 *_col 命名约定生成碰撞体") + + var rt_auto_miss: SceneRuntime = SceneRuntime.load_from_file( + _write_scene_glb("auto-miss", "Floor"), _scene_cfg({"colliders": "auto"}), "命名未命中" + ) + eq(rt_auto_miss.collider_count, 1, "auto 未命中时为兜底地面生成 1 个碰撞体") + is_true(rt_auto_miss.used_fallback_ground, "auto 未命中时标记使用了兜底地面") + + # none 模式:不生成碰撞体,也不兜底 + var rt_none: SceneRuntime = SceneRuntime.load_from_file(path, _scene_cfg({"colliders": "none"}), "无碰撞") + eq(rt_none.collider_count, 0, "none 模式不生成任何碰撞体") + is_false(rt_none.used_fallback_ground, "none 模式不兜底") + + rt_mesh.free() + rt_auto_hit.free() + rt_auto_miss.free() + rt_none.free() + + +func test_real_glb_gravity_and_wind_match_config() -> void: + var rt: SceneRuntime = SceneRuntime.load_from_file( + _write_scene_glb("gravity", "Floor"), _scene_cfg({"colliders": "mesh"}), "物理参数" + ) + + var gravity_area := rt.get_node_or_null("SceneGravityArea") as Area3D + not_null(gravity_area, "重力的 Area3D 已创建") + if gravity_area != null: + almost(gravity_area.gravity, 3.5, 1e-4, "重力大小按服务端配置生效") + almost(gravity_area.gravity_direction.y, -1.0, 1e-5, "重力方向向下") + eq(gravity_area.gravity_space_override, Area3D.SPACE_OVERRIDE_REPLACE, + "场景级重力用 REPLACE 覆盖(不改全局 ProjectSettings)") + + not_null(rt.get_node_or_null("SceneWindArea") as Area3D, "风的 Area3D 已创建") + almost(rt.wind_force_at(0.0).x, 2.0 * SceneRuntime.WIND_FORCE_SCALE, 1e-3, + "风力按服务端基础风向量生效") + contains(rt.describe(), "重力 3.5", "日志摘要含重力数值(可核对)") + contains(rt.describe(), "2.0,0.0,0.0", "日志摘要含基础风向量") + + rt.free() + + +func test_aabb_accumulates_transforms_for_offset_meshes() -> void: + # 网格整体位移到 x=10:包围盒必须跟着走,否则重力/风区域会算偏位(风力作用不到) + # 用 mesh 模式:只验证"包围盒是否跟着位移走",不引入 auto 未命中导致的兜底告警 + var rt: SceneRuntime = SceneRuntime.load_from_file( + _write_scene_glb("offset", "Floor", Vector3(10, 0, 0)), + _scene_cfg({"colliders": "mesh"}), + "位移场景", + ) + eq(rt.warnings.size(), 0, "带位移的真实 GLB 加载无降级告警") + eq(rt.collider_count, 1, "位移场景按 mesh 模式生成 1 个碰撞体") + + var box := _area_box(rt, "SceneGravityArea") + not_null(box, "重力区域的碰撞盒存在") + if box != null: + ok(box.position.x > 9.5 and box.position.x < 11.5, + "包围盒沿树累乘变换:区域中心跟着网格位移(x≈10)", "实际 x=%.2f" % box.position.x) + var box_shape := box.shape as BoxShape3D + not_null(box_shape, "碰撞盒是 BoxShape3D") + if box_shape != null: + ok(box_shape.size.x > 10.0, "区域水平尺寸覆盖到位移后的场景", "size.x=%.1f" % box_shape.size.x) + + # auto 未命中 → 兜底地面也应落在位移后的位置 + var rt_fallback: SceneRuntime = SceneRuntime.load_from_file( + _write_scene_glb("offset-plain", "FloorPlain", Vector3(10, 0, 0)), + _scene_cfg({"colliders": "auto"}), + "位移兜底", + ) + var ground := rt_fallback.get_node_or_null("FallbackGround") as StaticBody3D + not_null(ground, "兜底地面已生成") + if ground != null: + ok(ground.position.x > 9.5 and ground.position.x < 11.5, + "兜底地面跟着包围盒位移(x≈10)", "实际 x=%.2f" % ground.position.x) + + rt.free() + rt_fallback.free()