Files
opendance-windows/tests/test_scene_config.gd
T
zijunchiang 14ee5f7ec9 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。
2026-09-17 03:59:29 +08:00

436 lines
20 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()
# ---------------- 真实 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()