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
+56
View File
@@ -0,0 +1,56 @@
class_name AboutScreen
extends Control
## T-WIN-010:关于页面 + 开源声明(可滚动)
## 文档 §8.3 要求 Windows 客户端与 Android App 的「关于」页展示同款声明。
const UiKit = preload("res://src/ui/ui_kit.gd")
const Credits = preload("res://src/ui/credits.gd")
var _on_back: Callable = Callable()
func _init(on_back: Callable = Callable()) -> void:
_on_back = on_back
func _ready() -> void:
set_anchors_preset(Control.PRESET_FULL_RECT)
var root := UiKit.vbox(10)
root.set_anchors_preset(Control.PRESET_FULL_RECT)
root.offset_left = 24
root.offset_top = 18
root.offset_right = -24
root.offset_bottom = -18
add_child(root)
var header := UiKit.hbox(12)
header.add_child(UiKit.title("关于 OpenDance Studio"))
header.add_child(UiKit.expander())
var back := UiKit.button("返回主菜单", func(): if _on_back.is_valid(): _on_back.call())
header.add_child(back)
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 评分。"))
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.add_child(info_box)
root.add_child(info)
var credits_panel := UiKit.panel()
var credits_box := UiKit.vbox(4)
credits_box.add_child(UiKit.heading("开源项目声明"))
credits_box.add_child(UiKit.muted("共 %d 项;与 contracts/open-source-credits.md 及 THIRD_PARTY.md 保持一致。" % Credits.entry_count()))
var text := UiKit.rich_text()
text.bbcode_enabled = true
text.text = Credits.to_bbcode()
text.custom_minimum_size = Vector2(0, 320)
credits_box.add_child(text)
credits_panel.add_child(credits_box)
credits_panel.size_flags_vertical = Control.SIZE_EXPAND_FILL
root.add_child(credits_panel)
+63
View File
@@ -0,0 +1,63 @@
class_name Credits
extends RefCounted
## T-WIN-010:开源声明数据
##
## 与 contracts/open-source-credits.md(v0.1.0)逐条对应;
## 该表是**唯一来源**,任一端新增依赖须先回到 opendance-web 更新契约,
## 再同步本文件与 THIRD_PARTY.md。
##
## 关于页面必须展示完整列表并可滚动查看(文档 §8.3)。
const ENTRIES := [
{"name": "MediaPipe", "license": "Apache License 2.0", "url": "https://github.com/google-ai-edge/mediapipe", "copyright": "Copyright 2023 Google LLC", "usage": "33 点骨骼拓扑定义(契约来源,本端不含其代码)"},
{"name": "Three.js", "license": "MIT", "url": "https://github.com/mrdoob/three.js", "copyright": "", "usage": "Web 管理端 3D 预览"},
{"name": "@pixiv/three-vrm", "license": "MIT", "url": "https://github.com/pixiv/three-vrm", "copyright": "", "usage": "Web 管理端 VRM 加载"},
{"name": "Assimp", "license": "BSD-3-Clause", "url": "https://github.com/assimp/assimp", "copyright": "", "usage": "FBX/glTF 动作转换(可选)"},
{"name": "Godot Engine", "license": "MIT", "url": "https://github.com/godotengine/godot", "copyright": "Copyright Godot Engine contributors", "usage": "本客户端运行引擎与内置 glTF/VRM 导入器"},
{"name": "godot-vrm", "license": "MIT", "url": "https://github.com/V-Sekai/godot-vrm", "copyright": "", "usage": "VRM humanoid 扩展(本端改为按名映射,未使用其二进制)"},
{"name": "NestJS", "license": "MIT", "url": "https://github.com/nestjs/nest", "copyright": "", "usage": "Web 服务端"},
{"name": "TypeORM", "license": "MIT", "url": "https://github.com/typeorm/typeorm", "copyright": "", "usage": "Web 服务端 ORM"},
{"name": "OkHttp", "license": "Apache License 2.0", "url": "https://github.com/square/okhttp", "copyright": "", "usage": "Android 辅助 App 通信"},
{"name": "bvh", "license": "MIT", "url": "https://github.com/aimoonchen/bvh", "copyright": "", "usage": "BVH 解析参考实现"},
{"name": "React", "license": "MIT", "url": "https://github.com/facebook/react", "copyright": "", "usage": "Web 管理端界面"},
{"name": "Vite", "license": "MIT", "url": "https://github.com/vitejs/vite", "copyright": "", "usage": "Web 管理端构建"},
{"name": "CameraX", "license": "Apache License 2.0", "url": "https://developer.android.com/jetpack/androidx/releases/camera", "copyright": "", "usage": "Android 相机采集"},
{"name": "Jetpack Compose", "license": "Apache License 2.0", "url": "https://developer.android.com/jetpack/compose", "copyright": "", "usage": "Android 界面"},
{"name": "mysql2", "license": "MIT", "url": "https://github.com/sidorares/node-mysql2", "copyright": "", "usage": "Web 服务端数据库驱动"},
]
## 契约版本(与 contracts/VERSION 对齐)
const CONTRACT_VERSION := "0.1.0"
## 渲染为 BBCode(用于 RichTextLabel)
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("")
for i in ENTRIES.size():
var entry: Dictionary = ENTRIES[i]
lines.append("[b]%d. %s[/b]" % [i + 1, entry["name"]])
if str(entry["copyright"]) != "":
lines.append("  版权:%s" % entry["copyright"])
lines.append("  许可证:%s" % entry["license"])
lines.append("  用途:%s" % entry["usage"])
lines.append("  源链接:[url=%s]%s[/url]" % [entry["url"], entry["url"]])
lines.append("")
lines.append("[i]未使用 SMPL 模型及其衍生拓扑,统一采用 MediaPipe 33 点拓扑,以规避非商用许可限制。[/i]")
return "\n".join(lines)
## 导出为纯文本(用于 THIRD_PARTY.md 一致性自检)
static func to_plain_text() -> String:
var lines := PackedStringArray()
for entry in ENTRIES:
lines.append("%s | %s | %s" % [entry["name"], entry["license"], entry["url"]])
return "\n".join(lines)
static func entry_count() -> int:
return ENTRIES.size()
+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()
+301
View File
@@ -0,0 +1,301 @@
class_name MainMenuScreen
extends Control
## T-WIN-008:主界面(曲目/动作选择 + 连接状态 + 设置)
## T-WIN-009:来源信息展示
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")
var _song_list: ItemList
var _motion_list: ItemList
var _model_list: ItemList
var _source_panel: SourcePanel
var _status: Label
var _health: Label
var _api_input: LineEdit
var _start_button: Button
var _songs: Array = []
var _motions: Array = []
var _models: Array = []
var _selected_motion: CatalogTypes.Motion = null
var _selected_model: CatalogTypes.Model = null
var _on_start: Callable = Callable()
var _on_about: Callable = Callable()
var _on_open_settings: Callable = Callable()
func _init(on_start: Callable = Callable(), on_about: Callable = Callable()) -> void:
_on_start = on_start
_on_about = on_about
func _ready() -> void:
set_anchors_preset(Control.PRESET_FULL_RECT)
name = "MainMenu"
var root := UiKit.vbox(12)
root.set_anchors_preset(Control.PRESET_FULL_RECT)
root.offset_left = 24
root.offset_top = 18
root.offset_right = -24
root.offset_bottom = -18
add_child(root)
# ---- 顶栏 ----
var header := UiKit.hbox(12)
header.add_child(UiKit.title("OpenDance Studio"))
header.add_child(UiKit.expander())
_health = UiKit.label("", UiKit.FONT_SMALL, UiKit.COLOR_MUTED)
header.add_child(_health)
header.add_child(UiKit.button("刷新目录", _refresh_catalog))
header.add_child(UiKit.button("开源声明", func(): if _on_about.is_valid(): _on_about.call()))
root.add_child(header)
# ---- API 地址 ----
var api_row := UiKit.hbox(10)
api_row.add_child(UiKit.label("服务端地址", UiKit.FONT_SMALL, UiKit.COLOR_MUTED))
_api_input = UiKit.line_edit("http://127.0.0.1:3000", AppConfig.api_base_url)
api_row.add_child(_api_input)
api_row.add_child(UiKit.button("保存并测试", _apply_api_base))
api_row.add_child(UiKit.expander())
var listen := UiKit.label("接收地址 %s" % AppConfig.listen_url(), UiKit.FONT_SMALL, UiKit.COLOR_MUTED)
api_row.add_child(listen)
root.add_child(api_row)
# ---- 三栏选择 ----
var columns := UiKit.hbox(12)
columns.size_flags_vertical = 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"]))
var motion_column := _make_list_column("2. 选择动作(已完成预处理)")
_motion_list = motion_column["list"]
columns.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"]))
root.add_child(columns)
# ---- 来源 + 操作 ----
var bottom := UiKit.hbox(14)
_source_panel = SourcePanel.new("动作来源")
_source_panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
bottom.add_child(_source_panel)
var actions := UiKit.vbox(8)
_start_button = UiKit.button("开始跟练", _on_start_pressed)
_start_button.custom_minimum_size = Vector2(180, 46)
_start_button.disabled = true
actions.add_child(_start_button)
actions.add_child(UiKit.button("重新连接接收服务", _restart_receiver))
actions.add_child(UiKit.button("查看接收状态", _report_receiver))
bottom.add_child(actions)
root.add_child(bottom)
_status = UiKit.label("", UiKit.FONT_SMALL, UiKit.COLOR_MUTED)
root.add_child(_status)
_song_list.item_selected.connect(_on_song_selected)
_motion_list.item_selected.connect(_on_motion_selected)
_model_list.item_selected.connect(_on_model_selected)
CatalogStore.catalog_loaded.connect(_rebuild_lists)
CatalogStore.catalog_failed.connect(_on_catalog_failed)
_refresh_catalog()
## 把任意 Control 包进带背景的面板
func _wrap_in_panel(content: Control) -> PanelContainer:
var panel := UiKit.panel()
panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
panel.size_flags_vertical = Control.SIZE_EXPAND_FILL
panel.add_child(content)
return panel
## 生成「标题 + 列表」的一列,返回 { list, box }
func _make_list_column(title: String) -> Dictionary:
var box := UiKit.vbox(6)
box.size_flags_horizontal = Control.SIZE_EXPAND_FILL
box.size_flags_vertical = Control.SIZE_EXPAND_FILL
box.add_child(UiKit.label(title, UiKit.FONT_SMALL, UiKit.COLOR_ACCENT))
var list := ItemList.new()
list.size_flags_horizontal = Control.SIZE_EXPAND_FILL
list.size_flags_vertical = Control.SIZE_EXPAND_FILL
list.custom_minimum_size = Vector2(260, 260)
list.allow_reselect = true
box.add_child(list)
return {"list": list, "box": box}
func _refresh_catalog() -> void:
_set_status("正在从 %s 拉取目录…" % AppConfig.api_base_url)
_start_button.disabled = true
var health_result: Dictionary = await CatalogStore.check_health()
if health_result["ok"]:
var db: Dictionary = health_result["data"].get("database", {})
_health.text = "服务端正常 | DB %s" % str(db.get("server", "?"))
_health.add_theme_color_override("font_color", UiKit.COLOR_GOOD)
else:
_health.text = "服务端不可达"
_health.add_theme_color_override("font_color", UiKit.COLOR_BAD)
var result: Dictionary = await CatalogStore.refresh()
if not result["ok"]:
_on_catalog_failed(str(result["error"]))
func _on_catalog_failed(message: String) -> void:
_set_status("目录拉取失败:%s" % message)
_health.text = "目录拉取失败"
_health.add_theme_color_override("font_color", UiKit.COLOR_BAD)
func _rebuild_lists() -> void:
_songs = CatalogStore.songs()
_motions = CatalogStore.motions()
_models = CatalogStore.models()
_song_list.clear()
for song in _songs:
_song_list.add_item("%s — %s" % [song.title, song.artist])
_rebuild_motion_list(-1)
_model_list.clear()
var default_model := CatalogStore.default_model()
for model in _models:
var tag := " ★默认" if model.is_primary else ""
if default_model != null and model.id == default_model.id and not model.is_primary:
tag = " ★服务端推荐"
_model_list.add_item("%s [%s%s]%s" % [
model.name, model.format.to_upper(), (" v" + model.vrm_version) if model.vrm_version != "" else "", tag,
])
if _models.size() > 0:
var index := 0
if default_model != null:
for i in _models.size():
if _models[i].id == default_model.id:
index = i
break
_model_list.select(index)
_selected_model = _models[index]
_set_status("目录已加载:%d 首曲目 | %d 个动作 | %d 个模型(契约 v%s)" % [
_songs.size(), _motions.size(), _models.size(),
str(CatalogStore.contract_version.get("data-models", "?")),
])
_update_start_state()
func _rebuild_motion_list(song_index: int) -> void:
_motion_list.clear()
var filtered: Array = []
if song_index >= 0 and song_index < _songs.size():
var song = _songs[song_index]
for motion in CatalogStore.scorable_motions_for_song(song.id):
filtered.append(motion)
if filtered.is_empty():
# 该曲目没有可评分动作时,退回展示全部可评分动作
filtered = _all_scorable()
else:
filtered = _all_scorable()
for motion in filtered:
_motion_list.add_item("%s(%d 帧)" % [motion.name, motion.keypoint_frames])
_motions = filtered
if filtered.size() > 0:
_motion_list.select(0)
_selected_motion = filtered[0]
_source_panel.show_source(_selected_motion.source, _selected_motion.name)
else:
_selected_motion = null
_source_panel.clear()
_update_start_state()
func _all_scorable() -> Array:
var out: Array = []
for motion in CatalogStore.motions():
if motion.is_scorable():
out.append(motion)
return out
func _on_song_selected(index: int) -> void:
_rebuild_motion_list(index)
func _on_motion_selected(index: int) -> void:
if index >= 0 and index < _motions.size():
_selected_motion = _motions[index]
_source_panel.show_source(_selected_motion.source, _selected_motion.name)
_update_start_state()
func _on_model_selected(index: int) -> void:
if index >= 0 and index < _models.size():
_selected_model = _models[index]
_update_start_state()
func _update_start_state() -> void:
_start_button.disabled = _selected_motion == null
func _on_start_pressed() -> void:
if _selected_motion == null or not _on_start.is_valid():
return
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)
func _apply_api_base() -> void:
var value := _api_input.text.strip_edges()
if value == "":
_set_status("服务端地址不能为空")
return
AppConfig.apply_settings({"api_base_url": value})
_api_input.text = AppConfig.api_base_url
_set_status("已保存服务端地址:%s" % AppConfig.api_base_url)
await _refresh_catalog()
func _restart_receiver() -> void:
PoseReceiver.stop()
var err := PoseReceiver.start()
if err == OK:
_set_status("接收服务已启动:%s" % AppConfig.listen_url())
else:
_set_status("接收服务启动失败(端口 %d 可能被占用)" % AppConfig.listen_port)
func _report_receiver() -> void:
var snap := PoseReceiver.snapshot()
if snap.is_empty():
_set_status("接收服务未初始化")
return
_set_status("接收服务:%s,端口 %d,连接 %d,累计帧 %d,丢帧 %d,畸形帧 %d" % [
"运行中" if bool(snap.get("running", false)) else "已停止",
int(snap.get("port", 0)),
int(snap.get("connected", 0)),
int(snap.get("frames_total", 0)),
int(snap.get("frames_dropped", 0)),
int(snap.get("malformed_frames", 0)),
])
func _set_status(text: String) -> void:
_status.text = text
+115
View File
@@ -0,0 +1,115 @@
class_name ResultScreen
extends Control
## T-WIN-008:结算界面
const UiKit = preload("res://src/ui/ui_kit.gd")
const Scoring = preload("res://src/score/scoring.gd")
const SourcePanel = preload("res://src/ui/source_panel.gd")
var _summary: Dictionary = {}
var _source: Object = null
var _asset_name: String = ""
var _grade_label: Label
var _score_label: Label
var _stats_box: VBoxContainer
var _source_panel: SourcePanel
var _on_retry: Callable = Callable()
var _on_back: Callable = Callable()
var _on_about: Callable = Callable()
func _init(on_retry: Callable = Callable(), on_back: Callable = Callable(), on_about: Callable = Callable()) -> void:
_on_retry = on_retry
_on_back = on_back
_on_about = on_about
func _ready() -> void:
set_anchors_preset(Control.PRESET_FULL_RECT)
var root := UiKit.vbox(12)
root.set_anchors_preset(Control.PRESET_FULL_RECT)
root.offset_left = 40
root.offset_top = 30
root.offset_right = -40
root.offset_bottom = -30
add_child(root)
root.add_child(UiKit.title("本轮结算"))
var card := UiKit.panel()
var card_box := UiKit.vbox(6)
_grade_label = UiKit.label("—", 64, UiKit.COLOR_ACCENT)
_grade_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
card_box.add_child(_grade_label)
_score_label = UiKit.label("", UiKit.FONT_HEADING)
_score_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
card_box.add_child(_score_label)
_stats_box = UiKit.vbox(2)
card_box.add_child(_stats_box)
card.add_child(card_box)
root.add_child(card)
_source_panel = SourcePanel.new("动作来源")
root.add_child(_source_panel)
root.add_child(UiKit.expander())
var actions := UiKit.hbox(12)
actions.alignment = BoxContainer.ALIGNMENT_CENTER
actions.add_child(UiKit.button("再来一次", func(): if _on_retry.is_valid(): _on_retry.call()))
actions.add_child(UiKit.button("返回主菜单", func(): if _on_back.is_valid(): _on_back.call()))
actions.add_child(UiKit.button("开源声明", func(): if _on_about.is_valid(): _on_about.call()))
root.add_child(actions)
func show_summary(summary: Dictionary, source: Object = null, asset_name: String = "") -> void:
_summary = summary
_source = source
_asset_name = asset_name
refresh()
func refresh() -> void:
if _grade_label == null:
return
var final_score := float(_summary.get("final_score", 0.0))
var average := float(_summary.get("average_score", 0.0))
_grade_label.text = str(_summary.get("grade", "D"))
_grade_label.add_theme_color_override("font_color", Scoring.grade_color(average))
_score_label.text = "%s | 平均 %.0f 分 | 最终 %.0f 分" % [
Scoring.grade_label(average), average, final_score,
]
for child in _stats_box.get_children():
child.queue_free()
var completion := float(_summary.get("completion", 0.0)) * 100.0
var rows := [
["有效评分帧数", "%d 帧" % int(_summary.get("frames_scored", 0))],
["参考动作长度", "%d 帧" % int(_summary.get("reference_frames", 0))],
["动作完成度", "%.0f%%" % completion],
["最高单帧得分", "%.0f" % float(_summary.get("peak_score", 0.0))],
["最长连击", "%d 帧" % int(_summary.get("max_combo", 0))],
]
for row in rows:
var line := UiKit.hbox(10)
var key := UiKit.label(str(row[0]), UiKit.FONT_BODY, UiKit.COLOR_MUTED)
key.custom_minimum_size = Vector2(160, 0)
line.add_child(key)
line.add_child(UiKit.label(str(row[1]), UiKit.FONT_BODY))
_stats_box.add_child(line)
_stats_box.add_child(UiKit.spacer(6))
_stats_box.add_child(UiKit.muted(Scoring.summary_comment(average)))
if _source != null:
_source_panel.show_source(_source, _asset_name)
else:
_source_panel.clear()
+91
View File
@@ -0,0 +1,91 @@
class_name SourcePanel
extends PanelContainer
## T-WIN-009:模型 / 动作来源信息展示
##
## 契约 data-models.md §1 要求来源五元组在任何资产上都必须齐备;
## 合规要求(文档 §11.5)审核界面与客户端都要展示。
const UiKit = preload("res://src/ui/ui_kit.gd")
const CatalogTypes = preload("res://src/net/catalog_types.gd")
var _type_label: Label
var _name_label: Label
var _author_label: Label
var _license_label: LinkButton
var _warning: Label
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(4)
add_child(box)
var header := UiKit.hbox(8)
header.add_child(UiKit.label(title, UiKit.FONT_SMALL, UiKit.COLOR_ACCENT))
_type_label = UiKit.label("", UiKit.FONT_SMALL, UiKit.COLOR_MUTED)
header.add_child(_type_label)
box.add_child(header)
_name_label = UiKit.label("—", UiKit.FONT_BODY)
box.add_child(_name_label)
var meta := UiKit.hbox(14)
_author_label = UiKit.label("", UiKit.FONT_SMALL, UiKit.COLOR_MUTED)
meta.add_child(_author_label)
_license_label = LinkButton.new()
_license_label.add_theme_font_size_override("font_size", UiKit.FONT_SMALL)
meta.add_child(_license_label)
box.add_child(meta)
_warning = UiKit.label("", UiKit.FONT_SMALL, UiKit.COLOR_BAD)
_warning.visible = false
box.add_child(_warning)
clear()
func clear() -> void:
_name_label.text = "—"
_type_label.text = ""
_author_label.text = ""
_license_label.text = ""
_license_label.uri = ""
_license_label.visible = false
_warning.visible = false
func show_source(source: CatalogTypes.SourceInfo, asset_name: String = "") -> void:
if source == null:
clear()
return
if asset_name != "":
_name_label.text = "%s | %s" % [asset_name, source.source_name]
else:
_name_label.text = source.source_name
_type_label.text = "[%s]" % source.type_label()
_author_label.text = "作者:%s" % source.author
if source.license != "":
_license_label.text = "许可:%s ↗" % source.license
_license_label.uri = source.source_url
_license_label.visible = true
else:
_license_label.visible = false
# 合规提示:五元组不齐备时必须显式告警,而不是静默展示
_warning.visible = not source.is_complete()
if _warning.visible:
_warning.text = "⚠ 来源信息不完整,请联系管理员补全后再使用"
+161
View File
@@ -0,0 +1,161 @@
extends TestCase
## T-WIN-002 集成测试:直接调用**真实运行中**的 Web 服务端。
##
## 若服务端不可达则整组跳过(不伪装成通过)。
## 启动服务端:cd opendance-web/web-server && npm run start:prod
##
## 覆盖:
## - GET /health、/api/catalog/version、/api/catalog/export 的真实响应
## - NDJSON 关键点序列解析
## - **跨实现交叉校验**:本端 PoseFeatures 计算的 9 个关节角
## 与服务端 TypeScript 实现算出的 angles 必须一致
const ApiClient = preload("res://src/net/api_client.gd")
const CatalogTypes = preload("res://src/net/catalog_types.gd")
const KeypointSequence = preload("res://src/net/keypoint_sequence.gd")
const PoseFeatures = preload("res://src/score/pose_features.gd")
var _client: ApiClient = null
var _available := false
var _unavailable_reason := ""
func _ensure_client() -> bool:
if _client != null:
# 每次都要显式 skip:否则后续测试会静默 return,
# 被运行器判为「未执行任何断言」
if not _available:
skip("API 集成", "Web 服务端不可达(%s)" % _unavailable_reason)
return _available
var tree := Engine.get_main_loop() as SceneTree
_client = ApiClient.new("http://127.0.0.1:3000")
_client.timeout_seconds = 8.0
tree.root.add_child(_client)
var result: Dictionary = await _client.health()
_available = result["ok"] and int(result["status"]) == 200
if _available:
var data: Dictionary = result["data"]
ok(bool(data.get("database", {}).get("connected", false)), "服务端报告数据库已连接")
else:
_unavailable_reason = str(result.get("error", ""))
skip("API 集成", "Web 服务端不可达(%s)" % _unavailable_reason)
return _available
func test_health_reports_contract_version() -> void:
if not await _ensure_client():
return
var result: Dictionary = await _client.health()
is_true(result["ok"], "GET /health 成功")
var version: Dictionary = result["data"].get("contractVersion", {})
eq(str(version.get("rest-api", "")), "0.1.0", "契约 rest-api 版本为 0.1.0")
eq(str(version.get("websocket-keypoints", "")), "0.1.0", "契约 websocket-keypoints 版本为 0.1.0")
eq(str(version.get("skeleton-topology", "")), "0.1.0", "契约 skeleton-topology 版本为 0.1.0")
func test_catalog_version_returns_revision() -> void:
if not await _ensure_client():
return
var result: Dictionary = await _client.catalog_version()
is_true(result["ok"], "GET /api/catalog/version 成功")
var revision := str(result["data"].get("revision", ""))
eq(revision.length(), 64, "revision 为 64 位十六进制指纹")
func test_catalog_export_parses_into_typed_objects() -> void:
if not await _ensure_client():
return
var result: Dictionary = await _client.catalog_export()
is_true(result["ok"], "GET /api/catalog/export 成功")
var bundle: CatalogTypes.CatalogBundle = CatalogTypes.CatalogBundle.from_dict(result["data"])
not_null(bundle, "目录解析成功")
eq(bundle.songs.size(), result["data"].get("songs", []).size(), "曲目数量一致")
eq(bundle.motions.size(), result["data"].get("motions", []).size(), "动作数量一致")
eq(bundle.models.size(), result["data"].get("models", []).size(), "模型数量一致")
# 契约 §4.1:defaultModelId 由服务端解析,客户端直接用
var resolved := bundle.default_model()
if bundle.models.size() > 0:
not_null(resolved, "存在已发布模型时可解析出默认模型")
if resolved != null:
eq(resolved.status, "published", "默认模型状态为 published")
else:
is_null(resolved, "无已发布模型时默认模型为 null")
# 契约要求来源五元组齐备
for song in bundle.songs:
is_true(song.source.is_complete(), "曲目「%s」来源五元组齐备" % song.title)
for model in bundle.models:
is_true(model.source.is_complete(), "模型「%s」来源五元组齐备" % model.name)
func test_keypoint_sequence_matches_contract_and_local_math() -> void:
if not await _ensure_client():
return
var export_result: Dictionary = await _client.catalog_export()
var bundle: CatalogTypes.CatalogBundle = CatalogTypes.CatalogBundle.from_dict(export_result["data"])
var target: CatalogTypes.Motion = null
for motion in bundle.motions:
if motion.is_scorable():
target = motion
break
if target == null:
skip("关键点序列", "服务端暂无已完成预处理的动作(先上传并预处理一个 BVH)")
return
eq(target.keypoint_format, KeypointSequence.EXPECTED_FORMAT, "keypointFormat 与契约一致")
var sequence_result: Dictionary = await _client.motion_keypoints(target.id)
is_true(sequence_result["ok"], "GET /api/motions/%d/keypoints 成功" % target.id)
var sequence: KeypointSequence.ParsedSequence = KeypointSequence.parse_ndjson(str(sequence_result["data"]))
eq(sequence.skipped_lines, 0, "NDJSON 无跳过行")
eq(sequence.frame_count(), target.keypoint_frames, "帧数与 motion.keypointFrames 一致")
ok(sequence.frame_count() > 0, "序列非空")
# 每帧 33 点(一次性校验,避免刷屏式断言)
var shape_ok := true
for frame in sequence.frames:
if frame.points.size() != 33 or frame.confidence.size() != 33:
shape_ok = false
break
is_true(shape_ok, "每帧均为 33 个关键点 + 33 个置信度")
# 坐标落在 MediaPipe 归一化范围
var first: KeypointFrame.PoseFrame = sequence.frames[0]
var in_range := true
for p in first.points:
if p.x < -0.001 or p.x > 1.001 or p.y < -0.001 or p.y > 1.001:
in_range = false
break
is_true(in_range, "关键点坐标落在归一化范围")
# 帧率推导合理
var fps := sequence.estimated_fps()
ok(fps > 1.0 and fps < 240.0, "由时间戳推导的帧率合理", "实际 %.2f" % fps)
# ★ 跨实现交叉校验:服务端 TypeScript 与本端 GDScript 的关节角必须一致。
# GDScript 的 % 运算符不支持 %e,故用 str() 输出浮点。
var comparison := KeypointSequence.compare_angles(sequence, 1e-3)
ok(int(comparison["checked"]) > 0, "存在可比对的服务端关节角", "比对帧数 %d" % int(comparison["checked"]))
ok(
int(comparison["mismatched_frames"]) == 0,
"所有帧的 9 个关节角与服务端实现一致",
"不一致帧数 %d,最大偏差 %s" % [int(comparison["mismatched_frames"]), str(comparison["max_delta"])]
)
ok(float(comparison["max_delta"]) < 1e-3, "关节角最大偏差小于 1e-3", "实际 %s" % str(comparison["max_delta"]))
func test_unknown_route_returns_structured_error() -> void:
if not await _ensure_client():
return
var result: Dictionary = await _client.get_json("/api/songs/99999999")
is_false(result["ok"], "不存在的资源返回失败")
eq(int(result["status"]), 404, "状态码为 404")
ok(str(result["error"]).length() > 0, "错误信息非空", str(result["error"]))
+75
View File
@@ -0,0 +1,75 @@
extends TestCase
## T-WIN-010:开源声明一致性与完整性
const Credits = preload("res://src/ui/credits.gd")
## 文档 §8.2 模板中的最低 10 项
const REQUIRED_NAMES := [
"MediaPipe", "Three.js", "@pixiv/three-vrm", "Assimp", "Godot Engine",
"godot-vrm", "NestJS", "TypeORM", "OkHttp", "bvh",
]
func test_credits_cover_documented_minimum_set() -> void:
var names: Array = []
for entry in Credits.ENTRIES:
names.append(str(entry["name"]))
for required in REQUIRED_NAMES:
ok(names.has(required), "文档要求的开源项目「%s」已声明" % required)
ok(Credits.entry_count() >= 10, "声明条目不少于文档要求的 10 项", "实际 %d" % Credits.entry_count())
func test_every_entry_has_license_and_source_link() -> void:
for entry in Credits.ENTRIES:
var name := str(entry["name"])
ne(str(entry["license"]), "", "%s 有许可证" % name)
var url := str(entry["url"])
ok(url.begins_with("https://"), "%s 的源链接为 https" % name, url)
ne(str(entry["usage"]), "", "%s 注明了用途" % name)
func test_known_licenses_are_correct() -> void:
var expected := {
"MediaPipe": "Apache License 2.0",
"Godot Engine": "MIT",
"Assimp": "BSD-3-Clause",
"OkHttp": "Apache License 2.0",
"bvh": "MIT",
}
for entry in Credits.ENTRIES:
var name := str(entry["name"])
if expected.has(name):
eq(str(entry["license"]), expected[name], "%s 的许可证正确" % name)
func test_bbcode_contains_all_entries_and_no_smpl() -> void:
var text := Credits.to_bbcode()
for entry in Credits.ENTRIES:
contains(text, str(entry["name"]), "BBCode 含 %s" % str(entry["name"]))
contains(text, str(entry["url"]), "BBCode 含 %s 的链接" % str(entry["name"]))
contains(text, "SMPL", "BBCode 明确声明未使用 SMPL")
contains(text, "第三方开源项目声明", "BBCode 含声明标题")
contains(text, Credits.CONTRACT_VERSION, "BBCode 含契约版本")
func test_plain_text_export_is_parseable() -> void:
var text := Credits.to_plain_text()
var lines := text.split("\n")
eq(lines.size(), Credits.entry_count(), "纯文本导出行数等于条目数")
for line in lines:
var parts := line.split(" | ")
eq(parts.size(), 3, "每行三段:名称 | 许可证 | 链接")
func test_contract_version_matches_snapshot() -> void:
# contracts/VERSION 是唯一来源;本端 README/关于页/THIRD_PARTY 都必须与之一致
var file := FileAccess.open("res://contracts/VERSION", FileAccess.READ)
if file == null:
ok(false, "无法读取 contracts/VERSION 快照")
return
var content := file.get_as_text()
file.close()
contains(content, "rest-api: %s" % Credits.CONTRACT_VERSION, "契约快照 rest-api 版本一致")
contains(content, "websocket-keypoints: %s" % Credits.CONTRACT_VERSION, "契约快照 websocket-keypoints 版本一致")
contains(content, "skeleton-topology: %s" % Credits.CONTRACT_VERSION, "契约快照 skeleton-topology 版本一致")