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()