feat(win): T-WIN-008/009/010 主界面/游戏/结算/来源展示/关于页

This commit is contained in:
2026-09-14 20:04:54 +08:00
parent 7776f68f0b
commit 13d6dc915d
8 changed files with 1217 additions and 0 deletions
+355
View File
@@ -0,0 +1,355 @@
class_name GameScreen
extends Control
## T-WIN-008:游戏界面(3D 角色 + 实时评分 HUD)
## T-WIN-009:来源信息展示
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 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 _viewport: SubViewport
var _camera: Camera3D
var _model_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 _start_button: Button
var _stop_button: Button
var _motion: CatalogTypes.Motion = null
var _song: CatalogTypes.Song = null
var _model: CatalogTypes.Model = null
var _good_frames: int = 0
var _total_frames: int = 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(340, 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)
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)
var light := DirectionalLight3D.new()
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)
_model_host = Node3D.new()
_viewport.add_child(_model_host)
## 设置本轮要跟练的曲目与动作,并加载模型
func prepare(song: CatalogTypes.Song, motion: CatalogTypes.Motion, model: CatalogTypes.Model) -> void:
_song = song
_motion = motion
_model = model
_good_frames = 0
_total_frames = 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 "")
_set_status("正在下载并加载模型…")
_clear_model()
if model == null or model.file_url == "":
_fail("目录中没有可用模型(未发布任何模型)")
return
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)
_frame_camera_on(vrm.root)
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%%)" % [
model.name, vrm.vrm_version if vrm.vrm_version != "" else "—", vrm.bone_count, coverage,
])
if coverage < 60.0:
_grade_label.text = "骨骼覆盖偏低,动作可能不完整"
state = State.IDLE
_start_button.disabled = false
_refresh_connection_hint()
## 让相机框住模型
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 _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()