fix(scene): 包围盒沿树累乘变换(修掉入树前的 ERROR 与范围偏位)+ 21 项"真机等价"测试

缺陷: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。
This commit is contained in:
2026-09-17 03:59:29 +08:00
parent 86e5decd10
commit 14ee5f7ec9
11 changed files with 456 additions and 22 deletions
+54
View File
@@ -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()