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