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

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

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

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

489 lines
16 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
class_name GameScreen
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 }
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
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()
func _init(on_exit: Callable = Callable(), on_finished: Callable = Callable()) -> void:
_on_exit = on_exit
_on_finished = on_finished
func _ready() -> void:
set_anchors_preset(Control.PRESET_FULL_RECT)
name = "GameScreen"
var root := UiKit.hbox(14)
root.set_anchors_preset(Control.PRESET_FULL_RECT)
root.offset_left = 20
root.offset_top = 16
root.offset_right = -20
root.offset_bottom = -16
add_child(root)
# ---- 左侧:3D 视图 ----
var view_container := SubViewportContainer.new()
view_container.stretch = true
view_container.size_flags_horizontal = Control.SIZE_EXPAND_FILL
view_container.size_flags_vertical = Control.SIZE_EXPAND_FILL
_viewport = SubViewport.new()
_viewport.size = Vector2i(960, 720)
_viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
_viewport.transparent_bg = false
view_container.add_child(_viewport)
root.add_child(view_container)
_env_setup()
# ---- 右侧:HUD ----
var side := UiKit.vbox(10)
side.custom_minimum_size = Vector2(380, 0)
root.add_child(side)
side.add_child(UiKit.title("评分"))
var score_card := UiKit.panel()
var score_box := UiKit.vbox(4)
_score_label = UiKit.label("—", 52, UiKit.COLOR_ACCENT)
_score_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
score_box.add_child(_score_label)
_grade_label = UiKit.label("等待开始", UiKit.FONT_BODY, UiKit.COLOR_MUTED)
_grade_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
score_box.add_child(_grade_label)
_combo_label = UiKit.label("连击 0", UiKit.FONT_SMALL, UiKit.COLOR_MUTED)
_combo_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
score_box.add_child(_combo_label)
score_card.add_child(score_box)
side.add_child(score_card)
side.add_child(UiKit.label("动作进度", UiKit.FONT_SMALL, UiKit.COLOR_MUTED))
_progress = UiKit.progress_bar(0.0)
side.add_child(_progress)
_status_label = UiKit.label("", UiKit.FONT_SMALL, UiKit.COLOR_MUTED)
_status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
side.add_child(_status_label)
_hint_label = UiKit.label("", UiKit.FONT_SMALL, UiKit.COLOR_MUTED)
_hint_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
side.add_child(_hint_label)
_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)
_start_button = UiKit.button("开始跟练", _on_start_pressed)
_stop_button = UiKit.button("结束本轮", _on_stop_pressed)
_stop_button.disabled = true
actions.add_child(_start_button)
actions.add_child(_stop_button)
side.add_child(actions)
side.add_child(UiKit.button("返回主菜单", func(): if _on_exit.is_valid(): _on_exit.call()))
PoseReceiver.frame_received.connect(_on_frame_received)
ScoreService.score_updated.connect(_on_score_updated)
PoseReceiver.client_connected.connect(func(_d): _refresh_connection_hint())
PoseReceiver.client_disconnected.connect(func(_r): _refresh_connection_hint())
_refresh_connection_hint()
func _env_setup() -> void:
var world := WorldEnvironment.new()
var env := Environment.new()
env.background_mode = Environment.BG_COLOR
env.background_color = UiKit.COLOR_BG
env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
env.ambient_light_color = Color(1, 1, 1)
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)
_camera = Camera3D.new()
_camera.position = Vector3(0, 1.35, 2.4)
_camera.rotation_degrees = Vector3(-6, 0, 0)
_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)
## 设置本轮要跟练的场景、曲目与动作,并加载模型
## `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 = "—"
_grade_label.text = "正在准备…"
_start_button.disabled = true
_stop_button.disabled = true
_source_panel.show_source(motion.source if motion != null else null, motion.name if motion != null else "")
_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"]))
return
var path := str(cached["path"])
vrm = VrmLoader.load_from_file(path)
if not vrm.available or vrm.skeleton == null:
_fail("模型加载失败:%s" % (vrm.error if vrm.error != "" else "缺少骨骼"))
return
vrm.root.visible = true
_model_host.add_child(vrm.root)
# 角色落在场景出生点(无场景时即世界原点),再按相机模式取景
_place_character_on_spawn()
_apply_camera(0.0)
retarget = SkeletonRetarget.new(vrm.skeleton, vrm.bones)
retarget.capture_rest()
if not retarget.is_bound():
_fail("骨骼绑定失败,模型可能不受支持")
return
var coverage := vrm.coverage() * 100.0
_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)
if aabb.size == Vector3.ZERO:
return
var height := aabb.size.y
var center := aabb.get_center()
_camera.position = center + Vector3(0, 0, maxf(height * 1.5, 2.0))
_camera.rotation_degrees = Vector3(0, 0, 0)
# 略微俯视,看向模型上半身
_camera.position.y = center.y + height * 0.15
_camera.look_at(center + Vector3(0, height * 0.02, 0), Vector3.UP)
func _compute_aabb(node: Node) -> AABB:
var result := AABB()
var first := true
if node is MeshInstance3D:
var mesh := (node as MeshInstance3D).get_aabb()
var world := (node as MeshInstance3D).global_transform
# 用节点自身变换近似(骨架绑定姿态下足够用于定机位)
result = world * mesh
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
func _on_start_pressed() -> void:
if _motion == null:
_fail("未选择动作")
return
if not PoseReceiver.is_listening():
PoseReceiver.start()
if PoseReceiver.connected_client_count() == 0:
_set_status("尚未连接 Android 辅助 App,已开始计时但暂无可评分数据。")
_set_status("正在拉取参考动作序列…")
_start_button.disabled = true
var fetched: Dictionary = await CatalogStore.fetch_sequence(_motion.id)
if not fetched["ok"]:
_fail("参考序列拉取失败:%s" % str(fetched["error"]))
_start_button.disabled = false
return
var sequence = fetched["sequence"]
ScoreService.start_session(sequence.features, _motion.id, AppConfig.score_tolerance)
state = State.RUNNING
_stop_button.disabled = false
_progress.value = 0.0
_set_status("跟练进行中:参考 %d 帧(约 %.1f 秒)" % [
sequence.frame_count(), sequence.duration_ms() / 1000.0,
])
func _on_stop_pressed() -> void:
if state != State.RUNNING:
return
_finish_session("已手动结束")
func _finish_session(reason: String) -> void:
var summary := ScoreService.finish()
state = State.FINISHED
_stop_button.disabled = true
_start_button.disabled = false
_set_status("本轮结束(%s)" % reason)
if retarget != null:
retarget.reset()
if _on_finished.is_valid():
_on_finished.call(summary, _motion.source if _motion != null else null, _motion.name if _motion != null else "")
func _on_frame_received(frame: Object) -> void:
if state != State.RUNNING or retarget == null:
return
retarget.apply(frame, 4.0 / 3.0)
_total_frames += 1
func _on_score_updated(result: Dictionary) -> void:
var score := float(result.get("score", 0.0))
_score_label.text = "%d" % int(round(score))
_score_label.add_theme_color_override("font_color", Scoring.grade_color(score))
_grade_label.text = "%s | %s" % [Scoring.grade(score), Scoring.grade_label(score)]
_combo_label.text = "连击 %d | 最高连击 %d" % [int(result.get("combo", 0)), int(result.get("max_combo", 0))]
_progress.value = float(result.get("progress", 0.0)) * 100.0
if score >= Scoring.COMBO_THRESHOLD:
_good_frames += 1
# 参考序列走完自动结算
if bool(result.get("progress", 0.0) >= 0.999):
_finish_session("动作已完成")
func _refresh_connection_hint() -> void:
var count := PoseReceiver.connected_client_count()
if count > 0:
_hint_label.text = "● 已连接 %d 台设备:%s" % [count, AppConfig.listen_url()]
_hint_label.add_theme_color_override("font_color", UiKit.COLOR_GOOD)
elif PoseReceiver.is_listening():
_hint_label.text = "○ 等待 Android 辅助 App 连接 %s" % AppConfig.listen_url()
_hint_label.add_theme_color_override("font_color", UiKit.COLOR_MUTED)
else:
_hint_label.text = "○ 接收服务未启动(将在开始跟练时自动启动 %s)" % AppConfig.listen_url()
_hint_label.add_theme_color_override("font_color", UiKit.COLOR_MUTED)
func _set_status(text: String) -> void:
_status_label.text = text
func _fail(message: String) -> void:
state = State.FAILED
_set_status(message)
_grade_label.text = "无法开始"
_start_button.disabled = true
_stop_button.disabled = true
push_warning("[GameScreen] %s" % message)
func _clear_model() -> void:
if vrm != null:
if vrm.root != null and is_instance_valid(vrm.root) and vrm.root.get_parent() == _model_host:
_model_host.remove_child(vrm.root)
vrm.dispose()
vrm = null
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()