Files
opendance-windows/tests/test_scene_config.gd
T
zijunchiang 5581cbe05d feat(scene): 场景选择与物理落地 + 整包发布(T-WIN-012/013/014)
T-WIN-013:API 升级与场景选择
- 契约快照同步到 rest-api 0.5.0 / data-models 0.7.0 / database 0.4.0
- catalog_types.gd 新增 SceneConfig(物理/环境/出生点/相机)与 Scene;
  CatalogBundle 增加 scenes[];from_dict 按契约 7.1 再收敛一次
  (缺项默认、越界夹紧、非法枚举回退),与服务端 normalizeSceneConfig 同规则
- 新增 src/scene/scene_runtime.gd:GLB 载入 -> 碰撞体(mesh 三角网 /
  auto 命名约定 *_col、*_colonly / none 不兜底)-> 场景级重力(Area3D
  SPACE_OVERRIDE_REPLACE,不改全局 ProjectSettings)-> 风(每物理帧
  apply_central_force,阵风正弦叠加,支持 local 坐标系)-> 环境光与雾
  -> 出生点与相机(follow/fixed/orbit);无碰撞体时兜底地面
- CatalogStore 增加 scenes/default_scene/find_scene/ensure_scene_cached/
  fetch_thumbnail(场景缓存带 sha256 指纹,避免换包后仍用旧 GLB)
- 主界面改为 2x2 四个选择器(曲目/动作/模型/场景)+ 场景参数面板
  (缩略图 + 重力/摩擦/弹性/碰撞体/风摘要)
- 游戏界面载入场景并落地物理,角色落在出生点;场景缺失/下载失败/
  解析失败一律回退内置空场景并提示,不阻塞开始跟练

T-WIN-012:「关于」清单展示当前版本号
- 版本唯一来源为 project.godot 的 application/config/version(v0.2.0)
- about_manifest() 输出版本/引擎/契约/协议/地址/隐私清单;
  无头测试无 autoload,读取 AppConfig 走 get_node_or_null 容错
- Credits 的单个 CONTRACT_VERSION 常量改为 CONTRACT_VERSIONS 字典
  (六项契约各自独立递增),并双向核对 contracts/VERSION

T-WIN-014:发布必须是单个完整包
- build/package-release.sh 重写:只产出单个 zip + SHA256SUMS,
  清理历史 .partN,超限时告警而非回退分卷;新增源产物与解压后完整性
  自检(PE 头 MZ / pck 魔数 GDPC / exe+pck 同目录同名)与体积-上限对比
- deploy/proxy 相关的 Gitea IIS 256MB 上限说明见 opendance-web

测试:tests/test_scene_config.gd(12 例)+ tests/test_about.gd(4 例);
无头测试 687 通过 / 0 失败(10 个套件),verify.sh 全部通过
2026-09-17 00:45:23 +08:00

246 lines
12 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()