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