@@ -0,0 +1,245 @@
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 ( )