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:
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://dnpayvixv5cgc
|
||||
+62
-5
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://ctm7k5ax7nrha
|
||||
Reference in New Issue
Block a user