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 全部通过
This commit is contained in:
2026-09-17 00:45:23 +08:00
parent 47e11af7d0
commit 5581cbe05d
27 changed files with 2031 additions and 85 deletions
+81
View File
@@ -23,6 +23,8 @@ var last_error: String = ""
## 参考序列缓存:motion_id → KeypointSequence.ParsedSequence
var _sequence_cache: Dictionary = {}
## 缩略图缓存:url → Texture2D(失败缓存为 null)
var _thumbnail_cache: Dictionary = {}
var _client: ApiClient = null
@@ -62,10 +64,22 @@ func models() -> Array:
return bundle.models if bundle != null else []
func scenes() -> Array:
return bundle.scenes if bundle != null else []
func default_model() -> CatalogTypes.Model:
return bundle.default_model() if bundle != null else null
func default_scene() -> CatalogTypes.Scene:
return bundle.default_scene() if bundle != null else null
func find_scene(scene_id: int) -> CatalogTypes.Scene:
return bundle.find_scene(scene_id) if bundle != null else null
## 拉取目录。成功后 bundle 被替换并发出 catalog_loaded。
func refresh() -> Dictionary:
if is_loading:
@@ -90,6 +104,7 @@ func refresh() -> Dictionary:
bundle = parsed
contract_version = parsed.contract_version
_sequence_cache.clear()
_thumbnail_cache.clear()
catalog_loaded.emit()
return {"ok": true, "data": parsed}
@@ -167,3 +182,69 @@ func scorable_motions_for_song(song_id: int) -> Array:
if motion.is_scorable():
out.append(motion)
return out
# ---------------- 场景(T-WIN-013) ----------------
## 下载场景 GLB 到本地缓存;已存在则直接复用
## 返回 { ok, path, error, cached }
func ensure_scene_cached(scene: CatalogTypes.Scene) -> Dictionary:
if scene == null or not scene.is_usable():
return {"ok": false, "error": "场景缺少可下载的 GLB 地址"}
var path := scene_cache_path(scene)
if FileAccess.file_exists(path):
return {"ok": true, "path": path, "cached": true}
var result: Dictionary = await _client.get_binary(scene.glb_url)
if not result["ok"]:
return {"ok": false, "error": str(result["error"])}
var bytes: PackedByteArray = result["data"]
if bytes.is_empty():
return {"ok": false, "error": "场景文件为空"}
DirAccess.make_dir_recursive_absolute(path.get_base_dir())
var file := FileAccess.open(path, FileAccess.WRITE)
if file == null:
return {"ok": false, "error": "无法写入缓存 %s(%s)" % [path, error_string(FileAccess.get_open_error())]}
file.store_buffer(bytes)
file.close()
return {"ok": true, "path": path, "cached": false, "bytes": bytes.size()}
## 场景缓存路径:user://scenes/<id>-<sha 前 8 位>.glb
## 带指纹是为了服务端换包后不会继续用旧 GLB(模型缓存用的是文件名后缀,场景统一 .glb 必须区分)
static func scene_cache_path(scene: CatalogTypes.Scene) -> String:
var fingerprint := scene.file_sha256.left(8) if scene.file_sha256 != "" else "nohash"
return "user://scenes/%d-%s.glb" % [scene.id, fingerprint]
## 缩略图(服务端离线渲染的 PNG)。失败返回 null 并缓存该结果,避免重复请求。
func fetch_thumbnail(url: String) -> Texture2D:
if url == "":
return null
if _thumbnail_cache.has(url):
return _thumbnail_cache[url]
var result: Dictionary = await _client.get_binary(url)
if not result["ok"]:
_thumbnail_cache[url] = null
return null
var bytes: PackedByteArray = result["data"]
var image := Image.new()
var err := image.load_png_from_buffer(bytes)
if err != OK:
err = image.load_jpg_from_buffer(bytes)
if err != OK:
_thumbnail_cache[url] = null
return null
var texture := ImageTexture.create_from_image(image)
_thumbnail_cache[url] = texture
return texture
func clear_thumbnail_cache() -> void:
_thumbnail_cache.clear()
+286
View File
@@ -160,11 +160,256 @@ class Model extends RefCounted:
return format == "vrm"
## ---------------- 场景(契约 data-models §7,T-WIN-013) ----------------
##
## 场景与模型不同:**以 GLB 为主**(不做 humanoid 重绑定、不转 VRM),
## 并携带**物理参数**;服务端只存参数、仿真在本端(Godot 内置物理)。
## 本文件的 from_dict 会按契约 7.1 的边界再收敛一次 —— 服务端已收敛,
## 但客户端不能假设对端一定合法(旧版本服务端 / 人工改包)。
## 风:基础风 + 阵风(正弦叠加)
class Wind extends RefCounted:
var base: Vector3 = Vector3.ZERO
var gust_amplitude: float = 0.0
var gust_frequency: float = 0.2
var gust_direction: Vector3 = Vector3(1, 0, 0)
## world | local
var space: String = "world"
static func from_dict(d: Dictionary) -> Wind:
var out := Wind.new()
out.base = CatalogTypes._vec3(d.get("base"), Vector3.ZERO, 100.0)
out.gust_amplitude = CatalogTypes._num(d.get("gustAmplitude"), 0.0, 0.0, 100.0)
out.gust_frequency = CatalogTypes._num(d.get("gustFrequency"), 0.2, 0.01, 5.0)
out.gust_direction = CatalogTypes._vec3(d.get("gustDirection"), Vector3(1, 0, 0), 1.0)
out.space = "local" if str(d.get("space", "world")) == "local" else "world"
return out
## 是否完全无风(可用于跳过风场施加)
func is_calm() -> bool:
return base.is_zero_approx() and is_zero_approx(gust_amplitude)
## 某一时刻的风向量:基础风 + 阵风(正弦)
func sample(time_seconds: float) -> Vector3:
if is_calm():
return Vector3.ZERO
if is_zero_approx(gust_amplitude):
return base
var dir := gust_direction
if dir.is_zero_approx():
dir = Vector3(1, 0, 0)
var wave := sin(TAU * gust_frequency * time_seconds) * gust_amplitude
return base + dir.normalized() * wave
func summary() -> String:
if is_calm():
return "无风"
var text := "基础风 %.1f,%.1f,%.1f m/s" % [base.x, base.y, base.z]
if not is_zero_approx(gust_amplitude):
text += " | 阵风 %.1f m/s @ %.2f Hz(%s)" % [
gust_amplitude, gust_frequency, "局部坐标" if space == "local" else "世界坐标",
]
return text
class Ground extends RefCounted:
var friction: float = 0.6
var restitution: float = 0.0
## 可行走坡度上限(度)
var slope_limit: float = 45.0
static func from_dict(d: Dictionary) -> Ground:
var out := Ground.new()
out.friction = CatalogTypes._num(d.get("friction"), 0.6, 0.0, 1.0)
out.restitution = CatalogTypes._num(d.get("restitution"), 0.0, 0.0, 1.0)
out.slope_limit = CatalogTypes._num(d.get("slopeLimit"), 45.0, 0.0, 89.0)
return out
## 转成 Godot 物理材质(摩擦 / 弹性)
func to_physics_material() -> PhysicsMaterial:
var material := PhysicsMaterial.new()
material.friction = friction
material.bounce = restitution
return material
class ScenePhysics extends RefCounted:
## 重力向量(米/秒^2,默认 (0,-9.8,0))
var gravity: Vector3 = Vector3(0, -9.8, 0)
var wind: Wind = Wind.new()
var ground: Ground = Ground.new()
## auto | mesh | none
var colliders: String = "auto"
static func from_dict(d: Dictionary) -> ScenePhysics:
var out := ScenePhysics.new()
out.gravity = CatalogTypes._vec3(d.get("gravity"), Vector3(0, -9.8, 0), 200.0)
out.wind = Wind.from_dict(CatalogTypes._dict(d.get("wind")))
out.ground = Ground.from_dict(CatalogTypes._dict(d.get("ground")))
var colliders := str(d.get("colliders", "auto"))
out.colliders = colliders if colliders in ["mesh", "none"] else "auto"
return out
func gravity_magnitude() -> float:
return gravity.length()
class SceneEnvironment extends RefCounted:
var ambient_color: Color = Color(0.561, 0.706, 1.0)
var ambient_intensity: float = 1.0
## 0 = 关闭
var fog_density: float = 0.0
static func from_dict(d: Dictionary) -> SceneEnvironment:
var out := SceneEnvironment.new()
var raw := str(d.get("ambientColor", ""))
if raw.length() == 7 and raw.begins_with("#"):
out.ambient_color = Color.html(raw)
out.ambient_intensity = CatalogTypes._num(d.get("ambientIntensity"), 1.0, 0.0, 4.0)
out.fog_density = CatalogTypes._num(d.get("fogDensity"), 0.0, 0.0, 1.0)
return out
class SpawnPoint extends RefCounted:
var position: Vector3 = Vector3.ZERO
## 朝向(度,绕 Y 轴)
var yaw: float = 0.0
static func from_dict(d: Dictionary) -> SpawnPoint:
var out := SpawnPoint.new()
out.position = Vector3(
CatalogTypes._num(d.get("x"), 0.0, -1000.0, 1000.0),
CatalogTypes._num(d.get("y"), 0.0, -1000.0, 1000.0),
CatalogTypes._num(d.get("z"), 0.0, -1000.0, 1000.0),
)
out.yaw = CatalogTypes._num(d.get("yaw"), 0.0, -360.0, 360.0)
return out
class SceneCamera extends RefCounted:
## follow | fixed | orbit
var mode: String = "follow"
var distance: float = 4.5
var height: float = 1.6
static func from_dict(d: Dictionary) -> SceneCamera:
var out := SceneCamera.new()
var mode := str(d.get("mode", "follow"))
out.mode = mode if mode in ["fixed", "orbit"] else "follow"
out.distance = CatalogTypes._num(d.get("distance"), 4.5, 0.5, 50.0)
out.height = CatalogTypes._num(d.get("height"), 1.6, 0.0, 20.0)
return out
func label() -> String:
match mode:
"fixed": return "固定机位"
"orbit": return "环绕"
return "跟随"
## 契约 data-models §7.1(冻结)
class SceneConfig extends RefCounted:
var physics: ScenePhysics = ScenePhysics.new()
var environment: SceneEnvironment = SceneEnvironment.new()
var spawn_points: Array = []
var camera: SceneCamera = SceneCamera.new()
## 服务端默认值(与 web-server/src/scenes/scene-config.ts 的 SCENE_CONFIG_DEFAULTS 对齐)
static func defaults() -> SceneConfig:
var out := SceneConfig.new()
out.physics = ScenePhysics.new()
out.environment = SceneEnvironment.new()
out.spawn_points = [SpawnPoint.new()]
out.camera = SceneCamera.new()
return out
## 把任意输入收敛成合法配置:缺项用默认值、越界夹紧、非法枚举回退。
## **永不抛错** —— 与服务端 normalizeSceneConfig 同规则。
static func from_dict(d: Dictionary) -> SceneConfig:
var fb := SceneConfig.defaults()
var out := SceneConfig.new()
out.physics = ScenePhysics.from_dict(CatalogTypes._dict(d.get("physics")))
out.environment = SceneEnvironment.from_dict(CatalogTypes._dict(d.get("environment")))
out.camera = SceneCamera.from_dict(CatalogTypes._dict(d.get("camera")))
var raw_spawn: Variant = d.get("spawnPoints")
var points: Array = []
if typeof(raw_spawn) == TYPE_ARRAY and (raw_spawn as Array).size() > 0:
for item in raw_spawn:
points.append(SpawnPoint.from_dict(CatalogTypes._dict(item)))
points = points.slice(0, 16)
else:
points = fb.spawn_points
out.spawn_points = points
return out
func first_spawn() -> SpawnPoint:
if spawn_points.is_empty():
return SpawnPoint.new()
return spawn_points[0]
## 界面用的一行摘要(重力 / 风 / 摩擦)
func summary() -> String:
return "重力 %.1f m/s² | 摩擦 %.2f | 弹性 %.2f | %s | %s" % [
physics.gravity_magnitude(), physics.ground.friction, physics.ground.restitution,
physics.colliders, physics.wind.summary(),
]
## 契约 data-models §7:场景(GLB 为主)
class Scene extends RefCounted:
var id: int = 0
var name: String = ""
var glb_url: String = ""
var thumbnail_url: String = ""
var file_size: int = 0
var file_sha256: String = ""
var conversion_note: String = ""
var config: SceneConfig = SceneConfig.new()
var stats: Dictionary = {}
var source: SourceInfo = SourceInfo.new()
var status: String = ""
static func from_dict(d: Dictionary) -> Scene:
var out := Scene.new()
out.id = int(d.get("id", 0))
out.name = str(d.get("name", ""))
out.glb_url = CatalogTypes._nullable_string(d.get("glbUrl"))
out.thumbnail_url = CatalogTypes._nullable_string(d.get("thumbnailUrl"))
out.file_size = int(d.get("fileSize", 0) if d.get("fileSize") != null else 0)
out.file_sha256 = CatalogTypes._nullable_string(d.get("fileSha256"))
out.conversion_note = CatalogTypes._nullable_string(d.get("conversionNote"))
var raw_config: Variant = d.get("config")
if typeof(raw_config) == TYPE_DICTIONARY:
out.config = SceneConfig.from_dict(raw_config)
else:
# 服务端可能返回 null(理论上入库即收敛,这里只做防御)
out.config = SceneConfig.defaults()
var raw_stats: Variant = d.get("stats")
out.stats = raw_stats if typeof(raw_stats) == TYPE_DICTIONARY else {}
out.source = SourceInfo.from_dict(d)
out.status = str(d.get("status", ""))
return out
## 是否具备可实例化的条件(缺 GLB 时界面提示并回退内置空场景)
func is_usable() -> bool:
return glb_url != ""
func size_label() -> String:
if file_size <= 0:
return "—"
if file_size < 1024 * 1024:
return "%.0f KB" % (file_size / 1024.0)
return "%.1f MB" % (file_size / 1048576.0)
## 契约 §6:客户端一次性拉取的目录
class CatalogBundle extends RefCounted:
var songs: Array = []
var motions: Array = []
var models: Array = []
## 已发布场景(rest-api 0.5.0 起随目录下发)
var scenes: Array = []
var default_model_id: int = -1
var contract_version: Dictionary = {}
var generated_at: String = ""
@@ -177,6 +422,8 @@ class CatalogBundle extends RefCounted:
out.motions.append(Motion.from_dict(item))
for item in d.get("models", []):
out.models.append(Model.from_dict(item))
for item in d.get("scenes", []):
out.scenes.append(Scene.from_dict(item))
var default_id: Variant = d.get("defaultModelId")
out.default_model_id = int(default_id) if default_id != null else -1
out.contract_version = d.get("contractVersion", {})
@@ -201,6 +448,20 @@ class CatalogBundle extends RefCounted:
return model
return null
func find_scene(id: int) -> Scene:
for scene in scenes:
if scene.id == id:
return scene
return null
## 场景无「服务端推荐默认」字段(契约 7 未定义 defaultSceneId),
## 因此按列表顺序取第一个具备 GLB 的场景;都没有则返回 null(回退内置空场景)。
func default_scene() -> Scene:
for scene in scenes:
if scene.is_usable():
return scene
return null
func motions_for_song(song_id: int) -> Array:
var out: Array = []
for motion in motions:
@@ -233,3 +494,28 @@ static func _nullable_string(value: Variant) -> String:
if value == null:
return ""
return str(value)
## 空字典兜底(JSON 的 null / 缺失 / 非字典一律当空字典)
static func _dict(value: Variant) -> Dictionary:
return value if typeof(value) == TYPE_DICTIONARY else {}
## 数值收敛:非数值用默认值,越界夹紧(与服务端 num() 同规则)
static func _num(value: Variant, fallback: float, min_value: float, max_value: float) -> float:
var parsed := fallback
if typeof(value) == TYPE_FLOAT or typeof(value) == TYPE_INT:
var as_float := float(value)
if is_finite(as_float):
parsed = as_float
return clampf(parsed, min_value, max_value)
## 向量收敛:各分量 ±limit
static func _vec3(value: Variant, fallback: Vector3, limit: float) -> Vector3:
var raw := _dict(value)
return Vector3(
_num(raw.get("x"), fallback.x, -limit, limit),
_num(raw.get("y"), fallback.y, -limit, limit),
_num(raw.get("z"), fallback.z, -limit, limit),
)
+380
View File
@@ -0,0 +1,380 @@
class_name SceneRuntime
extends Node3D
## T-WIN-013:场景实例化与物理落地
##
## 职责边界:**服务端只存参数、不跑物理,仿真在本端**(见契约 data-models §7)。
## 本类把服务端下发的 `SceneConfig` 落地为 Godot 物理:
## 1. 载入 GLB 场景(`GLTFDocument`,与 VrmLoader 同一条零原生依赖路线);
## 2. 按 `colliders` 生成 `StaticBody3D` 碰撞体并套用 `ground` 物理材质;
## 3. 用 `Area3D`(SPACE_OVERRIDE_REPLACE)实现**场景级重力**,不污染全局 ProjectSettings;
## 4. 用 `Area3D` + 每物理帧 `apply_central_force` 施加风力,阵风按正弦叠加;
## 5. 环境光 / 雾与相机机位。
##
## 容错(文档 §5.6 第 4 条):场景缺失、GLB 解析失败、参数非法一律**不抛错、不阻塞开始跟练**,
## 改为记录 `warnings` 并回退到内置空场景 + 兜底地面。
const CatalogTypes = preload("res://src/net/catalog_types.gd")
signal wind_force_applied(force: Vector3)
## 风向量(米/秒)→ 力(牛顿)的换算系数。
## 契约只约束风的量纲(m/s),未约束受力模型;本端统一取该系数,
## 使 1 m/s 的风对 1 kg 刚体产生 10 N 的力(可调,便于目视与日志核对)。
const WIND_FORCE_SCALE := 10.0
## `colliders: auto` 时识别为碰撞体的节点名后缀(对齐 Godot/Blender 命名约定)
const AUTO_COLLIDER_SUFFIXES := ["_col", "-col", "_colonly", "-colonly", "_collision", "-collision", "_collider", "-collider"]
## 服务端 fogDensity 0~1 → Godot fog_density 的映射区间。
## Godot 的 fog_density 量纲更敏感(默认 0.01),直接用 0~1 会全屏糊死。
const FOG_DENSITY_MIN := 0.005
const FOG_DENSITY_MAX := 0.05
var config: CatalogTypes.SceneConfig = null
var scene_name: String = ""
## 生成出来的场景根节点(本节点自身是运行时容器)
var root: Node3D = null
var warnings: PackedStringArray = PackedStringArray()
var collider_count: int = 0
var used_fallback_ground: bool = false
var source_path: String = ""
var _material: PhysicsMaterial = null
var _gravity_area: Area3D = null
var _wind_area: Area3D = null
var _wind_time: float = 0.0
var _bounds: AABB = AABB()
var _last_wind_force: Vector3 = Vector3.ZERO
var _bodies_hit: int = 0
## 载入场景 GLB 并落地物理;任何失败都回退到内置空场景(不抛错)。
static func load_from_file(path: String, scene_config: CatalogTypes.SceneConfig, display_name: String) -> SceneRuntime:
var runtime := SceneRuntime.new()
runtime.scene_name = display_name
runtime.source_path = path
runtime.config = scene_config if scene_config != null else CatalogTypes.SceneConfig.defaults()
var scene_root: Node3D = null
if path == "" or not FileAccess.file_exists(path):
runtime.warnings.append("场景文件不存在:%s" % path)
else:
var document := GLTFDocument.new()
var state := GLTFState.new()
var err := document.append_from_file(path, state)
if err != OK:
runtime.warnings.append("场景 GLB 解析失败:%s" % error_string(err))
else:
var generated: Node = document.generate_scene(state)
if generated == null:
runtime.warnings.append("无法从场景 GLB 生成节点树")
elif not (generated is Node3D):
runtime.warnings.append("场景 GLB 根节点不是 Node3D")
generated.free()
else:
scene_root = generated as Node3D
if scene_root == null:
runtime.warnings.append("已回退到内置空场景(不影响开始跟练)")
runtime._assemble(scene_root)
return runtime
## 构造纯参数化的运行时(无 GLB,仅兜底地面)——服务端无可用场景时使用
static func create_empty(scene_config: CatalogTypes.SceneConfig, display_name: String = "内置空场景") -> SceneRuntime:
var runtime := SceneRuntime.new()
runtime.scene_name = display_name
runtime.config = scene_config if scene_config != null else CatalogTypes.SceneConfig.defaults()
runtime.warnings.append("使用内置空场景(服务端未发布可用场景或加载失败)")
runtime._assemble(null)
return runtime
func _assemble(scene_root: Node3D) -> void:
name = "SceneRuntime"
_material = config.physics.ground.to_physics_material()
if scene_root != null:
root = scene_root
add_child(root)
_bounds = _compute_aabb(self)
_build_colliders()
_build_gravity_area()
_build_wind_area()
set_physics_process(true)
# ---------------- 重力 ----------------
func _build_gravity_area() -> void:
var area := Area3D.new()
area.name = "SceneGravityArea"
# REPLACE:区域内的刚体完全改用场景重力,不动 ProjectSettings 的全局重力
area.gravity_space_override = Area3D.SPACE_OVERRIDE_REPLACE
area.gravity_point = false
var g: Vector3 = config.physics.gravity
area.gravity_direction = Vector3.DOWN if g.is_zero_approx() else g.normalized()
area.gravity = config.physics.gravity_magnitude()
area.collision_layer = 1
area.collision_mask = 1
area.add_child(_make_box_collision(_area_extents()))
add_child(area)
_gravity_area = area
if g.is_zero_approx():
warnings.append("场景重力为 0(失重)")
# ---------------- 风 ----------------
func _build_wind_area() -> void:
var area := Area3D.new()
area.name = "SceneWindArea"
area.gravity_space_override = Area3D.SPACE_OVERRIDE_DISABLED
area.collision_layer = 1
area.collision_mask = 1
area.monitoring = true
area.add_child(_make_box_collision(_area_extents()))
add_child(area)
_wind_area = area
func _physics_process(delta: float) -> void:
if config == null or _wind_area == null:
return
if config.physics.wind.is_calm():
_last_wind_force = Vector3.ZERO
_bodies_hit = 0
return
_wind_time += delta
var force := wind_force_at(_wind_time)
_last_wind_force = force
var affected := 0
for body in _wind_area.get_overlapping_bodies():
if body is RigidBody3D:
var rigid := body as RigidBody3D
rigid.apply_central_force(force)
affected += 1
_bodies_hit = affected
if affected > 0:
wind_force_applied.emit(force)
## 某个时刻的风力(牛顿)——**纯函数,可无头单测**
func wind_force_at(time_seconds: float) -> Vector3:
if config == null:
return Vector3.ZERO
var v: Vector3 = config.physics.wind.sample(time_seconds)
if config.physics.wind.space == "local":
v = global_transform.basis * v
return v * WIND_FORCE_SCALE
## 某个刚体受到的重力(牛顿)——诊断/单测用;实际重力由 SceneGravityArea 施加
func gravity_force_for(body: RigidBody3D) -> Vector3:
if config == null or body == null:
return Vector3.ZERO
return config.physics.gravity * body.mass
func gravity_magnitude() -> float:
return config.physics.gravity_magnitude() if config != null else 0.0
# ---------------- 碰撞体 ----------------
func _build_colliders() -> void:
var mode := config.physics.colliders
var meshes := _collect_meshes(self)
if mode == "mesh":
for mesh in meshes:
mesh.create_trimesh_collision()
collider_count += 1
elif mode == "auto":
for mesh in meshes:
var lower := String(mesh.name).to_lower()
var matched := ""
for suffix in AUTO_COLLIDER_SUFFIXES:
if lower.ends_with(suffix):
matched = suffix
break
if matched == "":
continue
mesh.create_trimesh_collision()
collider_count += 1
# `-colonly` 约定:该网格只提供碰撞,不参与渲染
if matched.ends_with("colonly"):
mesh.visible = false
else:
# colliders: none —— 尊重场景作者意图,不生成任何碰撞体,也不兜底
warnings.append("场景配置 colliders=none,本次不生成任何碰撞体")
_apply_material_to_static_bodies()
# auto / mesh 下一个碰撞体都没找到时兜底,避免角色无限下落
if collider_count == 0 and mode != "none":
_add_fallback_ground()
used_fallback_ground = true
warnings.append("未从场景中找到碰撞体(colliders=%s),已生成兜底地面" % mode)
func _apply_material_to_static_bodies() -> void:
for node in _collect_static_bodies(self):
if node.physics_material_override == null:
node.physics_material_override = _material
func _add_fallback_ground() -> void:
var body := StaticBody3D.new()
body.name = "FallbackGround"
var shape := CollisionShape3D.new()
var box := BoxShape3D.new()
# 以场景包围盒的水平尺寸(至少 40×40 米)铺一块薄板,顶面落在场景最低点
var width := maxf(_bounds.size.x, 40.0)
var depth := maxf(_bounds.size.z, 40.0)
var top := _bounds.position.y if _bounds.size != Vector3.ZERO else 0.0
box.size = Vector3(width, 0.2, depth)
shape.shape = box
body.add_child(shape)
body.position = Vector3(_bounds.get_center().x, top - 0.1, _bounds.get_center().z)
body.physics_material_override = _material
add_child(body)
collider_count += 1
# ---------------- 环境 ----------------
## 把场景的环境参数写进调用方已有的 Environment(不新建 WorldEnvironment,避免互相覆盖)
func apply_environment(env: Environment) -> void:
if env == null or config == null:
return
env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
env.ambient_light_color = config.environment.ambient_color
env.ambient_light_energy = config.environment.ambient_intensity
if config.environment.fog_density > 0.0:
env.fog_enabled = true
env.fog_light_color = config.environment.ambient_color
env.fog_density = _godot_fog_density()
else:
env.fog_enabled = false
func _godot_fog_density() -> float:
var d := clampf(config.environment.fog_density, 0.0, 1.0)
return clampf(d * (FOG_DENSITY_MAX - FOG_DENSITY_MIN) + FOG_DENSITY_MIN, FOG_DENSITY_MIN, FOG_DENSITY_MAX)
# ---------------- 出生点 / 相机 ----------------
## 场景第 index 个出生点的位姿(yaw 为度,绕 Y 轴);越界回退第一个
func spawn_transform(index: int = 0) -> Transform3D:
var points: Array = config.spawn_points
if points.is_empty():
return Transform3D.IDENTITY
var point: CatalogTypes.SpawnPoint = points[index] if index >= 0 and index < points.size() else points[0]
var basis := Basis(Vector3.UP, deg_to_rad(point.yaw))
return Transform3D(basis, point.position)
## 按场景相机配置摆机位;fixed 用出生点、follow 跟目标、orbit 绕目标缓慢环绕
func apply_camera(camera: Camera3D, target: Vector3, time_seconds: float = 0.0) -> void:
if camera == null or config == null:
return
var cfg: CatalogTypes.SceneCamera = config.camera
var focus := target
match cfg.mode:
"fixed":
focus = spawn_transform().origin
"orbit":
var angle := time_seconds * 0.35
var orbit_pos := focus + Vector3(sin(angle), 0.0, cos(angle)) * cfg.distance + Vector3(0, cfg.height, 0)
# look_at_from_position 不要求节点已在树内,便于无头单测逐值核对机位
camera.look_at_from_position(orbit_pos, focus + Vector3(0, cfg.height * 0.2, 0), Vector3.UP)
return
var position := focus + Vector3(0, cfg.height, cfg.distance)
camera.look_at_from_position(position, focus + Vector3(0, cfg.height * 0.2, 0), Vector3.UP)
# ---------------- 诊断 ----------------
## 一行摘要(日志 / 界面用),保证"目视 + 日志核对"可落地
func describe() -> String:
if config == null:
return "场景未配置"
return "场景「%s」:%s | 碰撞体 %d 个%s | 相机 %s" % [
scene_name if scene_name != "" else "未命名",
config.summary(),
collider_count,
"(含兜底地面)" if used_fallback_ground else "",
config.camera.label(),
]
func warnings_text() -> String:
return ";".join(warnings) if warnings.size() > 0 else ""
func last_wind_force() -> Vector3:
return _last_wind_force
func affected_body_count() -> int:
return _bodies_hit
# ---------------- 工具 ----------------
func _area_extents() -> Vector3:
var size := _bounds.size
if size == Vector3.ZERO:
return Vector3(200, 200, 200)
# 水平各留 2 倍余量、向上多留 50 米(角色可能被抛起),向下留到地面以下
return Vector3(size.x * 2.0 + 20.0, size.y + 60.0, size.z * 2.0 + 20.0)
func _make_box_collision(size: Vector3) -> CollisionShape3D:
var shape := CollisionShape3D.new()
var box := BoxShape3D.new()
box.size = size
shape.shape = box
shape.position = _bounds.get_center() + Vector3(0, 20.0, 0)
return shape
static func _collect_meshes(node: Node) -> Array:
var out: Array = []
if node is MeshInstance3D:
out.append(node)
for child in node.get_children():
out.append_array(_collect_meshes(child))
return out
static func _collect_static_bodies(node: Node) -> Array:
var out: Array = []
if node is StaticBody3D:
out.append(node)
for child in node.get_children():
out.append_array(_collect_static_bodies(child))
return out
static func _compute_aabb(node: Node) -> AABB:
var result := AABB()
var first := true
if node is MeshInstance3D:
var mesh_node := node as MeshInstance3D
if mesh_node.visible:
result = mesh_node.global_transform * mesh_node.get_aabb()
first = false
for child in node.get_children():
var child_aabb := _compute_aabb(child)
if child_aabb.size == Vector3.ZERO:
continue
if first:
result = child_aabb
first = false
else:
result = result.merge(child_aabb)
return result
+1
View File
@@ -0,0 +1 @@
uid://dnpayvixv5cgc
+62 -5
View File
@@ -2,7 +2,13 @@ class_name AboutScreen
extends Control
## T-WIN-010:关于页面 + 开源声明(可滚动)
## T-WIN-012:关于清单展示当前版本号(文档 §11.1:Windows 端在「关于」清单)
##
## 文档 §8.3 要求 Windows 客户端与 Android App 的「关于」页展示同款声明。
##
## 版本号**唯一来源**是 project.godot 的 application/config/version
## (与 project.yaml 的 version、导出产物名 opendance-windows-vX.Y.Z.zip、Git tag 一致),
## 本文件不再手写一份(文档 §11.2 第 1 条硬性要求)。
const UiKit = preload("res://src/ui/ui_kit.gd")
const Credits = preload("res://src/ui/credits.gd")
@@ -14,6 +20,49 @@ func _init(on_back: Callable = Callable()) -> void:
_on_back = on_back
## 当前运行版本(构建期由 project.godot 注入,运行期读取)
static func app_version() -> String:
return str(ProjectSettings.get_setting("application/config/version", "0.0.0"))
## 取 AppConfig 自动加载实例。测试在 `--script` 模式下没有 autoload,
## 因此这里必须容错返回 null,而不是直接引用 AppConfig 造成解析期错误。
static func _app_config() -> Node:
var loop := Engine.get_main_loop()
if loop is SceneTree:
return (loop as SceneTree).root.get_node_or_null("AppConfig")
return null
static func _config_call(method: String, fallback: String) -> String:
var cfg := _app_config()
if cfg != null and cfg.has_method(method):
return str(cfg.call(method))
return fallback
static func _config_property(name: String, fallback: String) -> String:
var cfg := _app_config()
if cfg != null:
var value: Variant = cfg.get(name)
if value != null:
return str(value)
return fallback
## 「关于」清单的一行行内容:版本 / 引擎 / 契约 / 运行地址 / 隐私
static func about_manifest() -> Array:
return [
{"label": "客户端版本", "value": "v%s" % app_version()},
{"label": "运行引擎", "value": "Godot %s" % str(Engine.get_version_info().get("string", "?"))},
{"label": "契约版本", "value": Credits.summary()},
{"label": "接收协议", "value": "websocket-keypoints v%s(33 点 / 239 字节帧)" % Credits.version_of("websocket-keypoints")},
{"label": "接收地址", "value": _config_call("listen_url", "ws://0.0.0.0:8765/pose")},
{"label": "服务端地址", "value": _config_property("api_base_url", "http://127.0.0.1:3000")},
{"label": "隐私说明", "value": "仅接收关键点,不接收、不存储任何视频画面"},
]
func _ready() -> void:
set_anchors_preset(Control.PRESET_FULL_RECT)
@@ -33,12 +82,20 @@ func _ready() -> void:
root.add_child(header)
var info := UiKit.panel()
var info_box := UiKit.vbox(2)
info_box.add_child(UiKit.label("Windows 客户端 v%s" % ProjectSettings.get_setting("application/config/version", "0.1.0"), UiKit.FONT_HEADING))
info_box.add_child(UiKit.muted("摄像头跳舞评分系统:接收 Android 辅助 App 通过 WebSocket 推送的 33 点骨骼关键点,驱动 VRM 角色并实时 DTW 评分。"))
var info_box := UiKit.vbox(4)
info_box.add_child(UiKit.heading("清单"))
for item in about_manifest():
var row := UiKit.hbox(8)
var key := UiKit.label("%s:" % str(item["label"]), UiKit.FONT_SMALL, UiKit.COLOR_MUTED)
key.custom_minimum_size = Vector2(96, 0)
row.add_child(key)
var value := UiKit.label(str(item["value"]), UiKit.FONT_SMALL)
value.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
value.size_flags_horizontal = Control.SIZE_EXPAND_FILL
row.add_child(value)
info_box.add_child(row)
info_box.add_child(UiKit.spacer(4))
info_box.add_child(UiKit.muted("契约版本:rest-api / data-models / skeleton-topology / websocket-keypoints / model-formats = v%s" % Credits.CONTRACT_VERSION))
info_box.add_child(UiKit.muted("本客户端仅接收关键点,不接收、不存储任何视频画面。"))
info_box.add_child(UiKit.muted("摄像头跳舞评分系统:接收 Android 辅助 App 通过 WebSocket 推送的 33 点骨骼关键点,驱动 VRM 角色并实时 DTW 评分。"))
info.add_child(info_box)
root.add_child(info)
+24 -3
View File
@@ -27,8 +27,28 @@ const ENTRIES := [
{"name": "mysql2", "license": "MIT", "url": "https://github.com/sidorares/node-mysql2", "copyright": "", "usage": "Web 服务端数据库驱动"},
]
## 契约版本(与 contracts/VERSION 对齐)
const CONTRACT_VERSION := "0.1.0"
## 契约版本(唯一来源:contracts/VERSION 快照)
## 六项各自独立递增(rest-api 已到 0.5.0 时 skeleton-topology 仍可能是 0.2.0),
## 因此不能用单个常量表示;界面展示统一走 summary()。
const CONTRACT_VERSIONS := {
"rest-api": "0.5.0",
"websocket-keypoints": "0.1.0",
"data-models": "0.7.0",
"skeleton-topology": "0.2.0",
"model-formats": "0.5.0",
"database": "0.4.0",
}
## 契约版本是「字典 + 求值」而非单个常量,避免新增契约时忘记同步展示
static func version_of(name: String) -> String:
return str(CONTRACT_VERSIONS.get(name, "?"))
static func summary() -> String:
var parts := PackedStringArray()
for key in CONTRACT_VERSIONS:
parts.append("%s v%s" % [key, CONTRACT_VERSIONS[key]])
return " | ".join(parts)
## 渲染为 BBCode(用于 RichTextLabel)
@@ -36,7 +56,8 @@ static func to_bbcode() -> String:
var lines := PackedStringArray()
lines.append("[b][font_size=20]第三方开源项目声明[/font_size][/b]")
lines.append("")
lines.append("本软件使用了以下开源项目,特此声明。契约版本 v%s。" % CONTRACT_VERSION)
lines.append("本软件使用了以下开源项目,特此声明。")
lines.append("契约版本:%s。" % summary())
lines.append("")
for i in ENTRIES.size():
var entry: Dictionary = ENTRIES[i]
+139 -6
View File
@@ -3,12 +3,15 @@ extends Control
## T-WIN-008:游戏界面(3D 角色 + 实时评分 HUD)
## T-WIN-009:来源信息展示
## T-WIN-013:场景载入与物理落地(重力 / 风 / 碰撞 / 环境 / 机位)
const UiKit = preload("res://src/ui/ui_kit.gd")
const Scoring = preload("res://src/score/scoring.gd")
const VrmLoader = preload("res://src/model/vrm_loader.gd")
const SkeletonRetarget = preload("res://src/anim/skeleton_retarget.gd")
const SourcePanel = preload("res://src/ui/source_panel.gd")
const ScenePanel = preload("res://src/ui/scene_panel.gd")
const SceneRuntime = preload("res://src/scene/scene_runtime.gd")
const CatalogTypes = preload("res://src/net/catalog_types.gd")
enum State { IDLE, PREPARING, RUNNING, FINISHED, FAILED }
@@ -16,10 +19,14 @@ enum State { IDLE, PREPARING, RUNNING, FINISHED, FAILED }
var state: State = State.IDLE
var vrm: VrmLoader.VrmModel = null
var retarget: SkeletonRetarget = null
var scene_runtime: SceneRuntime = null
var scene_warning: String = ""
var _viewport: SubViewport
var _camera: Camera3D
var _world_env: Environment
var _model_host: Node3D
var _scene_host: Node3D
var _score_label: Label
var _grade_label: Label
var _combo_label: Label
@@ -27,14 +34,18 @@ var _progress: ProgressBar
var _status_label: Label
var _hint_label: Label
var _source_panel: SourcePanel
var _scene_panel: ScenePanel
var _scene_note: Label
var _start_button: Button
var _stop_button: Button
var _motion: CatalogTypes.Motion = null
var _song: CatalogTypes.Song = null
var _model: CatalogTypes.Model = null
var _scene: CatalogTypes.Scene = null
var _good_frames: int = 0
var _total_frames: int = 0
var _elapsed: float = 0.0
var _on_exit: Callable = Callable()
var _on_finished: Callable = Callable()
@@ -74,7 +85,7 @@ func _ready() -> void:
# ---- 右侧:HUD ----
var side := UiKit.vbox(10)
side.custom_minimum_size = Vector2(340, 0)
side.custom_minimum_size = Vector2(380, 0)
root.add_child(side)
side.add_child(UiKit.title("评分"))
@@ -108,6 +119,14 @@ func _ready() -> void:
_source_panel = SourcePanel.new("动作来源")
side.add_child(_source_panel)
_scene_panel = ScenePanel.new("场景参数")
side.add_child(_scene_panel)
_scene_note = UiKit.label("", UiKit.FONT_SMALL, UiKit.COLOR_MUTED)
_scene_note.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_scene_note.visible = false
side.add_child(_scene_note)
side.add_child(UiKit.expander())
var actions := UiKit.hbox(8)
@@ -137,8 +156,11 @@ func _env_setup() -> void:
env.ambient_light_energy = 0.9
world.environment = env
_viewport.add_child(world)
# 场景的环境参数(环境光 / 雾)直接写进这个 Environment,不另建 WorldEnvironment
_world_env = env
var light := DirectionalLight3D.new()
light.name = "SceneSun"
light.rotation_degrees = Vector3(-35, 40, 0)
light.light_energy = 1.2
_viewport.add_child(light)
@@ -149,17 +171,25 @@ func _env_setup() -> void:
_camera.fov = 40.0
_viewport.add_child(_camera)
_scene_host = Node3D.new()
_scene_host.name = "SceneHost"
_viewport.add_child(_scene_host)
_model_host = Node3D.new()
_model_host.name = "ModelHost"
_viewport.add_child(_model_host)
## 设置本轮要跟练的曲目与动作,并加载模型
func prepare(song: CatalogTypes.Song, motion: CatalogTypes.Motion, model: CatalogTypes.Model) -> void:
## 设置本轮要跟练的场景、曲目与动作,并加载模型
## `scene` 可以为 null(服务端未发布场景)—— 此时用内置空场景,**不阻塞开始跟练**
func prepare(song: CatalogTypes.Song, motion: CatalogTypes.Motion, model: CatalogTypes.Model, scene: CatalogTypes.Scene = null) -> void:
_song = song
_motion = motion
_model = model
_scene = scene
_good_frames = 0
_total_frames = 0
_elapsed = 0.0
state = State.PREPARING
_progress.value = 0.0
_score_label.text = "—"
@@ -168,14 +198,21 @@ func prepare(song: CatalogTypes.Song, motion: CatalogTypes.Motion, model: Catalo
_stop_button.disabled = true
_source_panel.show_source(motion.source if motion != null else null, motion.name if motion != null else "")
_set_status("正在下载并加载模型…")
_scene_panel.show_scene(scene)
_set_status("正在载入场景…")
_clear_model()
_clear_scene()
var scene_note: String = await _load_scene(scene)
if scene_note != "":
scene_warning = scene_note
if model == null or model.file_url == "":
_fail("目录中没有可用模型(未发布任何模型)")
return
_set_status("正在下载并加载模型…")
var cached: Dictionary = await CatalogStore.ensure_model_cached(model)
if not cached["ok"]:
_fail("模型下载失败:%s" % str(cached["error"]))
@@ -189,7 +226,10 @@ func prepare(song: CatalogTypes.Song, motion: CatalogTypes.Motion, model: Catalo
vrm.root.visible = true
_model_host.add_child(vrm.root)
_frame_camera_on(vrm.root)
# 角色落在场景出生点(无场景时即世界原点),再按相机模式取景
_place_character_on_spawn()
_apply_camera(0.0)
retarget = SkeletonRetarget.new(vrm.skeleton, vrm.bones)
retarget.capture_rest()
@@ -198,17 +238,90 @@ func prepare(song: CatalogTypes.Song, motion: CatalogTypes.Motion, model: Catalo
return
var coverage := vrm.coverage() * 100.0
_set_status("模型就绪:%s(VRM %s,%d 骨骼,人形覆盖率 %.0f%%)" % [
_set_status("模型就绪:%s(VRM %s,%d 骨骼,人形覆盖率 %.0f%%)| %s" % [
model.name, vrm.vrm_version if vrm.vrm_version != "" else "—", vrm.bone_count, coverage,
scene_runtime.describe() if scene_runtime != null else "无场景",
])
if coverage < 60.0:
_grade_label.text = "骨骼覆盖偏低,动作可能不完整"
_show_scene_note(scene_warning)
state = State.IDLE
_start_button.disabled = false
_refresh_connection_hint()
func _show_scene_note(note: String) -> void:
if _scene_note == null:
return
_scene_note.visible = note != ""
_scene_note.text = "⚠ %s" % note if note != "" else ""
_scene_note.add_theme_color_override("font_color", UiKit.COLOR_BAD)
# ---------------- 场景(T-WIN-013) ----------------
## 载入场景并落地物理。返回非空字符串表示需要提示的降级原因(不阻塞开始跟练)。
func _load_scene(scene: CatalogTypes.Scene) -> String:
scene_warning = ""
var config: CatalogTypes.SceneConfig = null
if scene != null:
config = scene.config
if scene == null:
scene_runtime = SceneRuntime.create_empty(config)
scene_runtime.apply_environment(_world_env)
_scene_host.add_child(scene_runtime)
push_warning("[GameScreen] 未选择场景,使用内置空场景")
return "未选择场景,已使用内置空场景"
if not scene.is_usable():
scene_runtime = SceneRuntime.create_empty(config, scene.name)
scene_runtime.apply_environment(_world_env)
_scene_host.add_child(scene_runtime)
return "场景「%s」缺少 GLB 文件,已回退内置空场景" % scene.name
var cached: Dictionary = await CatalogStore.ensure_scene_cached(scene)
if not cached["ok"]:
scene_runtime = SceneRuntime.create_empty(config, scene.name)
scene_runtime.apply_environment(_world_env)
_scene_host.add_child(scene_runtime)
return "场景下载失败(%s),已回退内置空场景" % str(cached["error"])
scene_runtime = SceneRuntime.load_from_file(str(cached["path"]), config, scene.name)
scene_runtime.apply_environment(_world_env)
_scene_host.add_child(scene_runtime)
# 日志核对:重力 / 风 / 摩擦 / 碰撞体数量(文档 §5.6 第 3 条要求可核对)
print("[GameScreen] %s" % scene_runtime.describe())
if scene_runtime.warnings.size() > 0:
push_warning("[GameScreen] 场景降级:%s" % scene_runtime.warnings_text())
return scene_runtime.warnings_text()
return ""
## 角色落到场景出生点(含 yaw 朝向)
func _place_character_on_spawn() -> void:
if vrm == null or vrm.root == null:
return
var transform := Transform3D.IDENTITY
if scene_runtime != null:
transform = scene_runtime.spawn_transform(0)
vrm.root.transform = transform
## 按场景相机配置摆机位;无场景时退回原来的"框住模型"行为
func _apply_camera(time_seconds: float) -> void:
if vrm == null or vrm.root == null:
return
if scene_runtime != null:
var target := scene_runtime.spawn_transform(0).origin
# 以模型上半身为焦点,避免角色高出镜头
target.y += 1.0
scene_runtime.apply_camera(_camera, target, time_seconds)
return
_frame_camera_on(vrm.root)
## 让相机框住模型
func _frame_camera_on(root: Node3D) -> void:
var aabb := _compute_aabb(root)
@@ -347,9 +460,29 @@ func _clear_model() -> void:
retarget = null
func _clear_scene() -> void:
if scene_runtime != null:
if is_instance_valid(scene_runtime) and scene_runtime.get_parent() == _scene_host:
_scene_host.remove_child(scene_runtime)
scene_runtime.queue_free()
scene_runtime = null
scene_warning = ""
## 只在跟练进行中推进相机(orbit 模式需要时间轴),其余时候不动相机
func _process(delta: float) -> void:
if scene_runtime == null or vrm == null or _camera == null:
return
if scene_runtime.config == null or scene_runtime.config.camera.mode != "orbit":
return
_elapsed += delta
_apply_camera(_elapsed)
func _exit_tree() -> void:
if PoseReceiver.frame_received.is_connected(_on_frame_received):
PoseReceiver.frame_received.disconnect(_on_frame_received)
if ScoreService.score_updated.is_connected(_on_score_updated):
ScoreService.score_updated.disconnect(_on_score_updated)
_clear_model()
_clear_scene()
+4 -3
View File
@@ -94,10 +94,11 @@ func goto_about() -> void:
_show_only(_about)
func _on_start_session(song: Object, motion: Object, model: Object) -> void:
func _on_start_session(song: Object, motion: Object, model: Object, scene: Object = null) -> void:
_show_only(_game)
_game.prepare(song, motion, model)
set_status("准备跟练:%s" % (motion.name if motion != null else "未选择动作"))
_game.prepare(song, motion, model, scene)
var scene_name: String = scene.name if scene != null else "内置空场景"
set_status("准备跟练:%s | 场景 %s" % [(motion.name if motion != null else "未选择动作"), scene_name])
func _on_session_finished(summary: Dictionary, source: Object, asset_name: String) -> void:
+67 -12
View File
@@ -1,17 +1,21 @@
class_name MainMenuScreen
extends Control
## T-WIN-008:主界面(曲目/动作选择 + 连接状态 + 设置)
## T-WIN-008:主界面(曲目/动作/模型选择 + 连接状态 + 设置)
## T-WIN-009:来源信息展示
## T-WIN-013:场景选择(与曲目/动作/模型并列的第 4 个选择器)
const UiKit = preload("res://src/ui/ui_kit.gd")
const CatalogTypes = preload("res://src/net/catalog_types.gd")
const SourcePanel = preload("res://src/ui/source_panel.gd")
const ScenePanel = preload("res://src/ui/scene_panel.gd")
var _song_list: ItemList
var _motion_list: ItemList
var _model_list: ItemList
var _scene_list: ItemList
var _source_panel: SourcePanel
var _scene_panel: ScenePanel
var _status: Label
var _health: Label
var _api_input: LineEdit
@@ -20,8 +24,10 @@ var _start_button: Button
var _songs: Array = []
var _motions: Array = []
var _models: Array = []
var _scenes: Array = []
var _selected_motion: CatalogTypes.Motion = null
var _selected_model: CatalogTypes.Model = null
var _selected_scene: CatalogTypes.Scene = null
var _on_start: Callable = Callable()
var _on_about: Callable = Callable()
@@ -66,30 +72,42 @@ func _ready() -> void:
api_row.add_child(listen)
root.add_child(api_row)
# ---- 三栏选择 ----
var columns := UiKit.hbox(12)
columns.size_flags_vertical = Control.SIZE_EXPAND_FILL
# ---- 四栏选择(2×2,窗口 1280 宽下每列仍可读) ----
var grid := GridContainer.new()
grid.columns = 2
grid.add_theme_constant_override("h_separation", 12)
grid.add_theme_constant_override("v_separation", 10)
grid.size_flags_vertical = Control.SIZE_EXPAND_FILL
grid.size_flags_horizontal = Control.SIZE_EXPAND_FILL
var song_column := _make_list_column("1. 选择曲目")
_song_list = song_column["list"]
columns.add_child(_wrap_in_panel(song_column["box"]))
grid.add_child(_wrap_in_panel(song_column["box"]))
var motion_column := _make_list_column("2. 选择动作(已完成预处理)")
_motion_list = motion_column["list"]
columns.add_child(_wrap_in_panel(motion_column["box"]))
grid.add_child(_wrap_in_panel(motion_column["box"]))
var model_column := _make_list_column("3. 选择模型(VRM 优先)")
_model_list = model_column["list"]
columns.add_child(_wrap_in_panel(model_column["box"]))
grid.add_child(_wrap_in_panel(model_column["box"]))
root.add_child(columns)
var scene_column := _make_list_column("4. 选择场景(GLB + 物理参数)")
_scene_list = scene_column["list"]
grid.add_child(_wrap_in_panel(scene_column["box"]))
# ---- 来源 + 操作 ----
root.add_child(grid)
# ---- 来源 + 场景参数 + 操作 ----
var bottom := UiKit.hbox(14)
_source_panel = SourcePanel.new("动作来源")
_source_panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
bottom.add_child(_source_panel)
_scene_panel = ScenePanel.new("场景参数")
_scene_panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
bottom.add_child(_scene_panel)
var actions := UiKit.vbox(8)
_start_button = UiKit.button("开始跟练", _on_start_pressed)
_start_button.custom_minimum_size = Vector2(180, 46)
@@ -106,6 +124,7 @@ func _ready() -> void:
_song_list.item_selected.connect(_on_song_selected)
_motion_list.item_selected.connect(_on_motion_selected)
_model_list.item_selected.connect(_on_model_selected)
_scene_list.item_selected.connect(_on_scene_selected)
CatalogStore.catalog_loaded.connect(_rebuild_lists)
CatalogStore.catalog_failed.connect(_on_catalog_failed)
@@ -164,6 +183,7 @@ func _rebuild_lists() -> void:
_songs = CatalogStore.songs()
_motions = CatalogStore.motions()
_models = CatalogStore.models()
_scenes = CatalogStore.scenes()
_song_list.clear()
for song in _songs:
@@ -190,13 +210,39 @@ func _rebuild_lists() -> void:
_model_list.select(index)
_selected_model = _models[index]
_set_status("目录已加载:%d 首曲目 | %d 个动作 | %d 个模型(契约 v%s)" % [
_songs.size(), _motions.size(), _models.size(),
_rebuild_scene_list()
_set_status("目录已加载:%d 首曲目 | %d 个动作 | %d 个模型 | %d 个场景(契约 v%s)" % [
_songs.size(), _motions.size(), _models.size(), _scenes.size(),
str(CatalogStore.contract_version.get("data-models", "?")),
])
_update_start_state()
## T-WIN-013:场景列表;无可用场景时提示并回退内置空场景(不阻塞开始跟练)
func _rebuild_scene_list() -> void:
_scene_list.clear()
for scene in _scenes:
var tag := "" if scene.is_usable() else " ⚠ 缺少 GLB"
_scene_list.add_item("%s [%s]%s" % [scene.name, scene.size_label(), tag])
if _scenes.is_empty():
_selected_scene = null
_scene_panel.clear()
return
var default_scene := CatalogStore.default_scene()
var index := 0
if default_scene != null:
for i in _scenes.size():
if _scenes[i].id == default_scene.id:
index = i
break
_scene_list.select(index)
_selected_scene = _scenes[index]
_scene_panel.show_scene(_selected_scene)
func _rebuild_motion_list(song_index: int) -> void:
_motion_list.clear()
var filtered: Array = []
@@ -249,6 +295,15 @@ func _on_model_selected(index: int) -> void:
_update_start_state()
func _on_scene_selected(index: int) -> void:
if index >= 0 and index < _scenes.size():
_selected_scene = _scenes[index]
_scene_panel.show_scene(_selected_scene)
if not _selected_scene.is_usable():
_set_status("场景「%s」缺少 GLB 文件,将回退到内置空场景" % _selected_scene.name)
_update_start_state()
func _update_start_state() -> void:
_start_button.disabled = _selected_motion == null
@@ -259,7 +314,7 @@ func _on_start_pressed() -> void:
var song = null
if _selected_motion.song_id >= 0:
song = CatalogStore.bundle.find_song(_selected_motion.song_id) if CatalogStore.bundle != null else null
_on_start.call(song, _selected_motion, _selected_model)
_on_start.call(song, _selected_motion, _selected_model, _selected_scene)
func _apply_api_base() -> void:
+147
View File
@@ -0,0 +1,147 @@
class_name ScenePanel
extends PanelContainer
## T-WIN-013:场景选择后的**缩略图 + 参数摘要**面板(文档 §5.6 第 2 条)。
##
## 服务端只存参数、仿真在本端,因此选择场景后必须让用户直接看到
## 「重力 / 风 / 摩擦」这些真正会生效的数值,而不是只给一个名字。
const UiKit = preload("res://src/ui/ui_kit.gd")
const CatalogTypes = preload("res://src/net/catalog_types.gd")
const THUMBNAIL_SIZE := Vector2(150, 90)
var _thumbnail: TextureRect
var _placeholder: Label
var _name_label: Label
var _summary_label: Label
var _detail_label: Label
var _warning: Label
var _pending_url: String = ""
func _init(title: String = "场景参数") -> void:
var style := StyleBoxFlat.new()
style.bg_color = UiKit.COLOR_BG
style.corner_radius_top_left = 8
style.corner_radius_top_right = 8
style.corner_radius_bottom_left = 8
style.corner_radius_bottom_right = 8
style.content_margin_left = 12
style.content_margin_right = 12
style.content_margin_top = 8
style.content_margin_bottom = 8
add_theme_stylebox_override("panel", style)
var box := UiKit.vbox(6)
add_child(box)
box.add_child(UiKit.label(title, UiKit.FONT_SMALL, UiKit.COLOR_ACCENT))
var row := UiKit.hbox(10)
var thumb_holder := Control.new()
thumb_holder.custom_minimum_size = THUMBNAIL_SIZE
_thumbnail = TextureRect.new()
_thumbnail.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
_thumbnail.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
_thumbnail.set_anchors_preset(Control.PRESET_FULL_RECT)
_thumbnail.visible = false
thumb_holder.add_child(_thumbnail)
_placeholder = UiKit.label("无缩略图", UiKit.FONT_SMALL, UiKit.COLOR_MUTED)
_placeholder.set_anchors_preset(Control.PRESET_FULL_RECT)
_placeholder.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_placeholder.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
thumb_holder.add_child(_placeholder)
row.add_child(thumb_holder)
var texts := UiKit.vbox(3)
texts.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_name_label = UiKit.label("—", UiKit.FONT_BODY)
texts.add_child(_name_label)
_summary_label = UiKit.label("", UiKit.FONT_SMALL, UiKit.COLOR_MUTED)
_summary_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
texts.add_child(_summary_label)
_detail_label = UiKit.label("", UiKit.FONT_SMALL, UiKit.COLOR_MUTED)
_detail_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
texts.add_child(_detail_label)
row.add_child(texts)
box.add_child(row)
_warning = UiKit.label("", UiKit.FONT_SMALL, UiKit.COLOR_BAD)
_warning.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_warning.visible = false
box.add_child(_warning)
clear()
func clear() -> void:
_pending_url = ""
_name_label.text = "—"
_summary_label.text = "未选择场景时使用内置空场景(默认重力与无风)"
_detail_label.text = ""
_warning.visible = false
_thumbnail.texture = null
_thumbnail.visible = false
_placeholder.visible = true
_placeholder.text = "无缩略图"
## 展示一个场景:立即渲染参数摘要,缩略图异步补齐
func show_scene(scene: CatalogTypes.Scene) -> void:
if scene == null:
clear()
return
_name_label.text = "%s | %s" % [scene.name, scene.size_label()]
var cfg: CatalogTypes.SceneConfig = scene.config
_summary_label.text = cfg.summary() if cfg != null else "物理参数缺失(按默认值处理)"
var details := PackedStringArray()
details.append("环境光 %s(强度 %.2f)| 雾 %.2f" % [
"#" + (cfg.environment.ambient_color.to_html(false) if cfg != null else "8fb4ff"),
cfg.environment.ambient_intensity if cfg != null else 1.0,
cfg.environment.fog_density if cfg != null else 0.0,
])
details.append("出生点 %d 个 | 相机 %s(距离 %.1f,高度 %.1f)" % [
cfg.spawn_points.size() if cfg != null else 0,
cfg.camera.label() if cfg != null else "跟随",
cfg.camera.distance if cfg != null else 4.5,
cfg.camera.height if cfg != null else 1.6,
])
if scene.conversion_note != "":
details.append("转换说明:%s" % scene.conversion_note)
if not scene.source.is_complete():
details.append("⚠ 来源五元组不齐备")
_detail_label.text = "\n".join(details)
if not scene.is_usable():
_warning.visible = true
_warning.text = "⚠ 该场景缺少 GLB 文件,开始跟练时将回退到内置空场景"
else:
_warning.visible = false
_thumbnail.texture = null
_thumbnail.visible = false
_placeholder.visible = true
if scene.thumbnail_url == "":
_placeholder.text = "无缩略图"
return
_placeholder.text = "缩略图加载中…"
_pending_url = scene.thumbnail_url
_load_thumbnail(scene.thumbnail_url)
func _load_thumbnail(url: String) -> void:
var texture: Texture2D = await CatalogStore.fetch_thumbnail(url)
# 异步返回时用户可能已经切走,丢弃过期结果
if _pending_url != url or not is_instance_valid(self):
return
if texture == null:
_placeholder.text = "缩略图加载失败"
return
_thumbnail.texture = texture
_thumbnail.visible = true
_placeholder.visible = false
+1
View File
@@ -0,0 +1 @@
uid://ctm7k5ax7nrha