缺陷: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。
436 lines
20 KiB
GDScript
436 lines
20 KiB
GDScript
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()
|