diff --git a/icon.svg b/icon.svg
new file mode 100644
index 0000000..f3e8ba8
--- /dev/null
+++ b/icon.svg
@@ -0,0 +1,20 @@
+
diff --git a/project.godot b/project.godot
new file mode 100644
index 0000000..3f64df1
--- /dev/null
+++ b/project.godot
@@ -0,0 +1,55 @@
+; OpenDance Studio —— Windows 客户端(Godot 4)
+;
+; 契约版本:见 contracts/VERSION(与 opendance-web 冻结的 v0.1.0 对齐)
+; 渲染方式选 gl_compatibility:兼容性最好,便于在 Linux CI 上无 GPU 导出。
+
+config_version=5
+
+[application]
+
+config/name="OpenDance Studio"
+config/description="摄像头跳舞评分系统 —— Windows 客户端。接收 Android 辅助 App 的 33 点骨骼关键点,驱动 VRM 角色并 DTW 实时评分。"
+config/version="0.1.0"
+run/main_scene="res://scenes/main.tscn"
+config/features=PackedStringArray("4.7", "GL Compatibility")
+config/icon="res://icon.svg"
+boot_splash/show_image=false
+
+[audio]
+
+buses/default_bus_layout=""
+
+[autoload]
+
+AppConfig="*res://src/autoload/app_config.gd"
+CatalogStore="*res://src/autoload/catalog_store.gd"
+PoseReceiver="*res://src/autoload/pose_receiver.gd"
+ScoreService="*res://src/autoload/score_service.gd"
+
+[display]
+
+window/size/viewport_width=1280
+window/size/viewport_height=720
+window/size/window_width_override=1280
+window/size/window_height_override=720
+window/stretch/mode="canvas_items"
+window/stretch/aspect="expand"
+
+[file_customization]
+
+folder_colors={
+"res://src/": "blue",
+"res://scenes/": "green",
+"res://tests/": "purple"
+}
+
+[input_devices]
+
+pointing/emulate_touch_from_mouse=false
+
+[rendering]
+
+renderer/rendering_method="gl_compatibility"
+renderer/rendering_method.mobile="gl_compatibility"
+textures/canvas_textures/default_texture_filter=1
+anti_aliasing/quality/msaa_3d=1
diff --git a/scenes/main.tscn b/scenes/main.tscn
new file mode 100644
index 0000000..dec2022
--- /dev/null
+++ b/scenes/main.tscn
@@ -0,0 +1,12 @@
+[gd_scene load_steps=2 format=3]
+
+[ext_resource type="Script" path="res://src/ui/main.gd" id="1_main"]
+
+[node name="Main" type="Control"]
+layout_mode = 3
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+script = ExtResource("1_main")
diff --git a/src/autoload/app_config.gd b/src/autoload/app_config.gd
new file mode 100644
index 0000000..2ba77bb
--- /dev/null
+++ b/src/autoload/app_config.gd
@@ -0,0 +1,114 @@
+extends Node
+
+## T-WIN-001/T-WIN-002:客户端配置
+##
+## 持久化到 user://opendance.cfg(ConfigFile),不入仓库。
+## 所有可调参数集中在此,便于打包后由用户修改而无需重新编译。
+
+signal loaded()
+signal changed()
+
+const CONFIG_PATH := "user://opendance.cfg"
+const SECTION := "opendance"
+
+## Web 服务端基地址(契约 servers[0])
+var api_base_url: String = "http://127.0.0.1:3000"
+## 本端 WebSocket 监听地址,Android App 主动连入(契约 §1 ws://:8765/pose)
+var listen_host: String = "0.0.0.0"
+var listen_port: int = 8765
+## WebSocket 子协议(契约 §1)
+var ws_subprotocol: String = "opendance.pose.v1"
+
+## DTW 带宽(帧)
+var dtw_band: int = 15
+## 评分容差:平均每步代价达到该值时得分归零
+var score_tolerance: float = 0.25
+## 显示镜像预览(Android 前置摄像头镜像时用于观感一致)
+var mirror_preview: bool = true
+## 全屏启动
+var fullscreen: bool = false
+
+var is_loaded: bool = false
+
+
+func _ready() -> void:
+ if not is_loaded:
+ load_from_disk()
+
+
+func load_from_disk() -> void:
+ var config := ConfigFile.new()
+ var err := config.load(CONFIG_PATH)
+ if err == OK:
+ api_base_url = str(config.get_value(SECTION, "api_base_url", api_base_url))
+ listen_host = str(config.get_value(SECTION, "listen_host", listen_host))
+ listen_port = int(config.get_value(SECTION, "listen_port", listen_port))
+ ws_subprotocol = str(config.get_value(SECTION, "ws_subprotocol", ws_subprotocol))
+ dtw_band = int(config.get_value(SECTION, "dtw_band", dtw_band))
+ score_tolerance = float(config.get_value(SECTION, "score_tolerance", score_tolerance))
+ mirror_preview = bool(config.get_value(SECTION, "mirror_preview", mirror_preview))
+ fullscreen = bool(config.get_value(SECTION, "fullscreen", fullscreen))
+ else:
+ # 首次运行:写入默认值,便于用户直接编辑
+ save_to_disk()
+
+ _normalize()
+ is_loaded = true
+ loaded.emit()
+ changed.emit()
+
+
+func save_to_disk() -> void:
+ _normalize()
+ var config := ConfigFile.new()
+ config.set_value(SECTION, "api_base_url", api_base_url)
+ config.set_value(SECTION, "listen_host", listen_host)
+ config.set_value(SECTION, "listen_port", listen_port)
+ config.set_value(SECTION, "ws_subprotocol", ws_subprotocol)
+ config.set_value(SECTION, "dtw_band", dtw_band)
+ config.set_value(SECTION, "score_tolerance", score_tolerance)
+ config.set_value(SECTION, "mirror_preview", mirror_preview)
+ config.set_value(SECTION, "fullscreen", fullscreen)
+ config.save(CONFIG_PATH)
+
+
+## 用字典批量更新(设置界面用),随后自动持久化
+func apply_settings(values: Dictionary) -> void:
+ for key in values:
+ if key in self:
+ set(key, values[key])
+ save_to_disk()
+ changed.emit()
+
+
+func reset_to_defaults() -> void:
+ api_base_url = "http://127.0.0.1:3000"
+ listen_host = "0.0.0.0"
+ listen_port = 8765
+ ws_subprotocol = "opendance.pose.v1"
+ dtw_band = 15
+ score_tolerance = 0.25
+ mirror_preview = true
+ fullscreen = false
+ save_to_disk()
+ changed.emit()
+
+
+func _normalize() -> void:
+ api_base_url = api_base_url.strip_edges().rstrip("/")
+ if api_base_url == "":
+ api_base_url = "http://127.0.0.1:3000"
+ if not api_base_url.begins_with("http://") and not api_base_url.begins_with("https://"):
+ api_base_url = "http://" + api_base_url
+ listen_port = clampi(listen_port, 1, 65535)
+ dtw_band = clampi(dtw_band, 1, 120)
+ score_tolerance = clampf(score_tolerance, 0.01, 5.0)
+
+
+## 供 UI 显示:本端 WebSocket 对外地址
+func listen_url() -> String:
+ return "ws://%s:%d/pose" % [listen_host, listen_port]
+
+
+func config_path() -> String:
+ return ProjectSettings.globalize_path(CONFIG_PATH)
diff --git a/src/autoload/app_config.gd.uid b/src/autoload/app_config.gd.uid
new file mode 100644
index 0000000..9a504de
--- /dev/null
+++ b/src/autoload/app_config.gd.uid
@@ -0,0 +1 @@
+uid://csmakru5y6laa
diff --git a/src/autoload/catalog_store.gd b/src/autoload/catalog_store.gd
new file mode 100644
index 0000000..c82dc59
--- /dev/null
+++ b/src/autoload/catalog_store.gd
@@ -0,0 +1,169 @@
+extends Node
+
+## T-WIN-002:曲目 / 动作 / 模型目录缓存
+##
+## 数据来源:契约 GET /api/catalog/export(只含 status = published 的资源)。
+## 参考关键点序列:GET /api/motions/{id}/keypoints(NDJSON)。
+
+signal catalog_loaded()
+signal catalog_failed(message: String)
+signal sequence_loaded(motion_id: int, frame_count: int)
+signal sequence_failed(motion_id: int, message: String)
+
+const CatalogTypes = preload("res://src/net/catalog_types.gd")
+const ApiClient = preload("res://src/net/api_client.gd")
+const KeypointSequence = preload("res://src/net/keypoint_sequence.gd")
+const VrmLoader = preload("res://src/model/vrm_loader.gd")
+
+var bundle: CatalogTypes.CatalogBundle = null
+var contract_version: Dictionary = {}
+var revision: String = ""
+var is_loading: bool = false
+var last_error: String = ""
+
+## 参考序列缓存:motion_id → KeypointSequence.ParsedSequence
+var _sequence_cache: Dictionary = {}
+var _client: ApiClient = null
+
+
+func _ready() -> void:
+ _client = ApiClient.new(AppConfig.api_base_url)
+ add_child(_client)
+ AppConfig.changed.connect(_on_config_changed)
+
+
+func _on_config_changed() -> void:
+ if _client != null:
+ _client.set_base_url(AppConfig.api_base_url)
+
+
+func client() -> ApiClient:
+ return _client
+
+
+## 服务端可达性检查(契约 GET /health)
+func check_health() -> Dictionary:
+ return await _client.health()
+
+
+func is_loaded() -> bool:
+ return bundle != null
+
+
+func songs() -> Array:
+ return bundle.songs if bundle != null else []
+
+
+func motions() -> Array:
+ return bundle.motions if bundle != null else []
+
+
+func models() -> Array:
+ return bundle.models if bundle != null else []
+
+
+func default_model() -> CatalogTypes.Model:
+ return bundle.default_model() if bundle != null else null
+
+
+## 拉取目录。成功后 bundle 被替换并发出 catalog_loaded。
+func refresh() -> Dictionary:
+ if is_loading:
+ return {"ok": false, "error": "正在加载中"}
+ is_loading = true
+ last_error = ""
+
+ var version_result: Dictionary = await _client.catalog_version()
+ if version_result["ok"]:
+ contract_version = version_result["data"].get("contractVersion", {})
+ revision = str(version_result["data"].get("revision", ""))
+
+ var result: Dictionary = await _client.catalog_export()
+ is_loading = false
+
+ if not result["ok"]:
+ last_error = str(result["error"])
+ catalog_failed.emit(last_error)
+ return result
+
+ var parsed: CatalogTypes.CatalogBundle = CatalogTypes.CatalogBundle.from_dict(result["data"])
+ bundle = parsed
+ contract_version = parsed.contract_version
+ _sequence_cache.clear()
+ catalog_loaded.emit()
+ return {"ok": true, "data": parsed}
+
+
+## 拉取某动作的关键点序列(带缓存)
+## 返回 { ok, sequence, error }
+func fetch_sequence(motion_id: int) -> Dictionary:
+ if _sequence_cache.has(motion_id):
+ return {"ok": true, "sequence": _sequence_cache[motion_id], "cached": true}
+
+ var motion := bundle.find_motion(motion_id) if bundle != null else null
+ if motion != null and not motion.is_scorable():
+ var reason := "动作 #%d 尚未完成关键点预处理(当前状态:%s)" % [motion_id, motion.preprocess_label()]
+ sequence_failed.emit(motion_id, reason)
+ return {"ok": false, "error": reason}
+
+ var result: Dictionary = await _client.motion_keypoints(motion_id)
+ if not result["ok"]:
+ var message := str(result["error"])
+ sequence_failed.emit(motion_id, message)
+ return {"ok": false, "error": message}
+
+ var sequence: KeypointSequence.ParsedSequence = KeypointSequence.parse_ndjson(str(result["data"]))
+ if sequence.frame_count() == 0:
+ var message := "关键点序列为空或格式不符(跳过 %d 行)" % sequence.skipped_lines
+ sequence_failed.emit(motion_id, message)
+ return {"ok": false, "error": message}
+
+ _sequence_cache[motion_id] = sequence
+ sequence_loaded.emit(motion_id, sequence.frame_count())
+ return {"ok": true, "sequence": sequence}
+
+
+func cached_sequence(motion_id: int) -> KeypointSequence.ParsedSequence:
+ return _sequence_cache.get(motion_id, null)
+
+
+func clear_sequence_cache() -> void:
+ _sequence_cache.clear()
+
+
+## 下载模型文件到本地缓存;已存在则直接复用
+## 返回 { ok, path, error, cached }
+func ensure_model_cached(model: CatalogTypes.Model) -> Dictionary:
+ if model == null or model.file_url == "":
+ return {"ok": false, "error": "模型缺少可下载的文件地址"}
+
+ var path := VrmLoader.cache_path_for(model.id, model.file_url)
+ if FileAccess.file_exists(path):
+ return {"ok": true, "path": path, "cached": true}
+
+ var result: Dictionary = await _client.get_binary(model.file_url)
+ if not result["ok"]:
+ return {"ok": false, "error": str(result["error"])}
+
+ var bytes: PackedByteArray = result["data"]
+ if bytes.is_empty():
+ return {"ok": false, "error": "模型文件为空"}
+
+ DirAccess.make_dir_recursive_absolute(path.get_base_dir())
+ var file := FileAccess.open(path, FileAccess.WRITE)
+ if file == null:
+ return {"ok": false, "error": "无法写入缓存 %s(%s)" % [path, error_string(FileAccess.get_open_error())]}
+ file.store_buffer(bytes)
+ file.close()
+ return {"ok": true, "path": path, "cached": false, "bytes": bytes.size()}
+
+
+## 单曲目下的可评分动作
+func scorable_motions_for_song(song_id: int) -> Array:
+ var out: Array = []
+ if bundle == null:
+ return out
+ for motion in bundle.motions_for_song(song_id):
+ if motion.is_scorable():
+ out.append(motion)
+ return out
diff --git a/src/autoload/catalog_store.gd.uid b/src/autoload/catalog_store.gd.uid
new file mode 100644
index 0000000..2cca703
--- /dev/null
+++ b/src/autoload/catalog_store.gd.uid
@@ -0,0 +1 @@
+uid://dk0t5uraxyawn
diff --git a/src/autoload/pose_receiver.gd b/src/autoload/pose_receiver.gd
new file mode 100644
index 0000000..ceea2d6
--- /dev/null
+++ b/src/autoload/pose_receiver.gd
@@ -0,0 +1,110 @@
+extends Node
+
+## T-WIN-003:关键点接收服务(autoload 封装)
+##
+## 把 PoseServer 绑定到 AppConfig 的监听配置,并把最新一帧暴露给渲染/评分链路。
+
+signal started(port: int)
+signal start_failed(reason: String)
+signal client_connected(device: String)
+signal client_disconnected(reason: String)
+signal frame_received(frame: KeypointFrame.PoseFrame)
+signal stats_updated(stats: Dictionary)
+
+const PoseServer = preload("res://src/net/pose_server.gd")
+const KeypointFrame = preload("res://src/net/keypoint_frame.gd")
+
+var server: PoseServer = null
+## 最近一帧(驱动 VRM 与评分);无数据时为 null
+var latest_frame: KeypointFrame.PoseFrame = null
+var last_frame_at_ms: int = 0
+var last_stats: Dictionary = {}
+
+## 是否处于「接收中」
+var active: bool = false
+
+
+func _ready() -> void:
+ server = PoseServer.new()
+ server.name = "PoseServer"
+ add_child(server)
+
+ server.listening.connect(_on_listening)
+ server.listen_failed.connect(_on_listen_failed)
+ server.client_connected.connect(_on_client_connected)
+ server.client_disconnected.connect(_on_client_disconnected)
+ server.frame_received.connect(_on_frame_received)
+ server.stats_updated.connect(_on_stats_updated)
+
+
+func start() -> Error:
+ if server == null:
+ return FAILED
+ server.expected_fps = 30
+ var err := server.listen(AppConfig.listen_host, AppConfig.listen_port)
+ if err == OK:
+ active = true
+ return err
+
+
+func stop() -> void:
+ if server != null:
+ server.stop()
+ active = false
+ latest_frame = null
+
+
+func is_listening() -> bool:
+ return server != null and server.is_running()
+
+
+func connected_client_count() -> int:
+ return server.connected_count() if server != null else 0
+
+
+## 设置要下发给 Android 端的参数(契约 §3.3 config)
+func apply_remote_config(config: Dictionary) -> void:
+ if server == null:
+ return
+ if config.has("mirror"):
+ server.mirror = bool(config["mirror"])
+ if config.has("targetFps"):
+ server.expected_fps = int(config["targetFps"])
+ if config.has("upperBodyOnly"):
+ server.upper_body_only = bool(config["upperBodyOnly"])
+ if config.has("resolution"):
+ server.resolution = str(config["resolution"])
+ server.broadcast_config()
+
+
+func snapshot() -> Dictionary:
+ return server.snapshot() if server != null else {}
+
+
+func _on_listening(port: int) -> void:
+ started.emit(port)
+
+
+func _on_listen_failed(port: int, reason: String) -> void:
+ active = false
+ start_failed.emit("监听 %d 端口失败:%s" % [port, reason])
+
+
+func _on_client_connected(_client_id: int, device: String) -> void:
+ client_connected.emit(device)
+
+
+func _on_client_disconnected(_client_id: int, reason: String) -> void:
+ latest_frame = null
+ client_disconnected.emit(reason)
+
+
+func _on_frame_received(frame: KeypointFrame.PoseFrame) -> void:
+ latest_frame = frame
+ last_frame_at_ms = Time.get_ticks_msec()
+ frame_received.emit(frame)
+
+
+func _on_stats_updated(stats: Dictionary) -> void:
+ last_stats = stats
+ stats_updated.emit(stats)
diff --git a/src/autoload/pose_receiver.gd.uid b/src/autoload/pose_receiver.gd.uid
new file mode 100644
index 0000000..9a5c975
--- /dev/null
+++ b/src/autoload/pose_receiver.gd.uid
@@ -0,0 +1 @@
+uid://dkxdsbrbi7hsx
diff --git a/src/autoload/score_service.gd b/src/autoload/score_service.gd
new file mode 100644
index 0000000..da02cb5
--- /dev/null
+++ b/src/autoload/score_service.gd
@@ -0,0 +1,124 @@
+extends Node
+
+## T-WIN-007/T-WIN-008:评分服务
+##
+## 把「实时关键点流」与「服务端参考序列」接进 DTW 引擎,产出可展示的分数。
+## 纯逻辑部分集中在 Scoring 类中(src/score/scoring.gd),便于无头测试。
+
+signal session_started(motion_id: int, reference_frames: int)
+signal score_updated(result: Dictionary)
+signal session_finished(summary: Dictionary)
+
+const DtwMatcher = preload("res://src/score/dtw.gd")
+const PoseFeatures = preload("res://src/score/pose_features.gd")
+const Scoring = preload("res://src/score/scoring.gd")
+
+var active: bool = false
+var motion_id: int = -1
+var reference: Array = []
+var _dtw: DtwMatcher.StreamingDtw = null
+var _tolerance: float = 0.25
+var _smoothing: float = 0.25
+var _smoothed_score: float = 0.0
+var _peak_score: float = 0.0
+var _combo: int = 0
+var _max_combo: int = 0
+var _frames_scored: int = 0
+var _score_sum: float = 0.0
+var _last_result: Dictionary = {}
+
+
+func _ready() -> void:
+ PoseReceiver.frame_received.connect(_on_frame_received)
+
+
+## 用参考序列开启一次评分
+func start_session(reference_features: Array, target_motion_id: int = -1, tolerance: float = -1.0) -> void:
+ stop_session()
+ reference = reference_features
+ motion_id = target_motion_id
+ _tolerance = tolerance if tolerance > 0.0 else AppConfig.score_tolerance
+ _dtw = DtwMatcher.StreamingDtw.new(reference, AppConfig.dtw_band)
+ _smoothed_score = 0.0
+ _peak_score = 0.0
+ _combo = 0
+ _max_combo = 0
+ _frames_scored = 0
+ _score_sum = 0.0
+ _last_result = {}
+ active = true
+ session_started.emit(motion_id, reference.size())
+
+
+func stop_session() -> void:
+ active = false
+ _dtw = null
+ reference = []
+
+
+func is_active() -> bool:
+ return active
+
+
+## 推进一帧姿态,返回本次评分结果
+func push_frame(frame: Object) -> Dictionary:
+ if not active or _dtw == null or frame == null:
+ return {}
+
+ var feature := PoseFeatures.build(frame)
+ var raw := _dtw.push(feature)
+ var normalized: float = raw["normalized"]
+ var instant := Scoring.score_from_cost(normalized, _tolerance)
+
+ _frames_scored += 1
+ _score_sum += instant
+ _smoothed_score = Scoring.smooth(_smoothed_score, instant, _smoothing)
+ _peak_score = maxf(_peak_score, instant)
+ if instant >= Scoring.COMBO_THRESHOLD:
+ _combo += 1
+ _max_combo = maxi(_max_combo, _combo)
+ else:
+ _combo = 0
+
+ _last_result = {
+ "score": _smoothed_score,
+ "instant": instant,
+ "peak": _peak_score,
+ "cost": normalized,
+ "combo": _combo,
+ "max_combo": _max_combo,
+ "progress": _dtw.progress(),
+ "reference_cursor": _dtw.reference_cursor(),
+ "frames": _frames_scored,
+ "grade": Scoring.grade(_smoothed_score),
+ }
+ score_updated.emit(_last_result)
+ return _last_result
+
+
+func last_result() -> Dictionary:
+ return _last_result
+
+
+## 结束并给出总结
+func finish() -> Dictionary:
+ var average := _score_sum / float(maxi(_frames_scored, 1))
+ var summary := {
+ "motion_id": motion_id,
+ "frames_scored": _frames_scored,
+ "average_score": average,
+ "peak_score": _peak_score,
+ "final_score": _smoothed_score,
+ "max_combo": _max_combo,
+ "grade": Scoring.grade(_smoothed_score),
+ "reference_frames": reference.size(),
+ "completion": _dtw.progress() if _dtw != null else 0.0,
+ }
+ active = false
+ session_finished.emit(summary)
+ return summary
+
+
+func _on_frame_received(frame: Object) -> void:
+ if active:
+ push_frame(frame)
diff --git a/src/autoload/score_service.gd.uid b/src/autoload/score_service.gd.uid
new file mode 100644
index 0000000..001dadd
--- /dev/null
+++ b/src/autoload/score_service.gd.uid
@@ -0,0 +1 @@
+uid://cyyv3nvnqlqma
diff --git a/src/ui/main.gd b/src/ui/main.gd
new file mode 100644
index 0000000..76f8af5
--- /dev/null
+++ b/src/ui/main.gd
@@ -0,0 +1,116 @@
+extends Control
+
+## 应用外壳:构建界面骨架、管理页面切换、启动接收服务。
+##
+## 界面全部由代码构建(见 src/ui/ui_kit.gd 的说明),
+## scenes/main.tscn 只挂载本脚本。
+
+const UiKit = preload("res://src/ui/ui_kit.gd")
+const MainMenuScreen = preload("res://src/ui/main_menu.gd")
+const GameScreen = preload("res://src/ui/game_screen.gd")
+const ResultScreen = preload("res://src/ui/result_screen.gd")
+const AboutScreen = preload("res://src/ui/about_screen.gd")
+
+var _screen_host: Control
+var _status_label: Label
+var _menu: MainMenuScreen
+var _game: GameScreen
+var _result: ResultScreen
+var _about: AboutScreen
+
+
+func _ready() -> void:
+ set_anchors_preset(Control.PRESET_FULL_RECT)
+ UiKit.apply_root_theme(self)
+
+ var bg := ColorRect.new()
+ bg.color = UiKit.COLOR_BG
+ bg.set_anchors_preset(Control.PRESET_FULL_RECT)
+ bg.mouse_filter = Control.MOUSE_FILTER_IGNORE
+ add_child(bg)
+
+ var layout := UiKit.vbox(0)
+ layout.set_anchors_preset(Control.PRESET_FULL_RECT)
+ add_child(layout)
+
+ _screen_host = Control.new()
+ _screen_host.size_flags_vertical = Control.SIZE_EXPAND_FILL
+ _screen_host.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ _screen_host.clip_contents = true
+ layout.add_child(_screen_host)
+
+ var status_bar := UiKit.panel(UiKit.COLOR_PANEL, 0)
+ _status_label = UiKit.label("就绪", UiKit.FONT_SMALL, UiKit.COLOR_MUTED)
+ status_bar.add_child(_status_label)
+ layout.add_child(status_bar)
+
+ _build_screens()
+
+ if AppConfig.fullscreen:
+ DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_FULLSCREEN)
+
+ # 启动 WebSocket 接收服务(Android 辅助 App 连入本端)
+ var err := PoseReceiver.start()
+ if err == OK:
+ set_status("接收服务已启动:%s | 服务端 %s" % [AppConfig.listen_url(), AppConfig.api_base_url])
+ else:
+ set_status("接收服务启动失败(端口 %d 可能被占用);服务端 %s" % [AppConfig.listen_port, AppConfig.api_base_url])
+
+ PoseReceiver.client_connected.connect(func(device): set_status("Android 设备已连接:%s" % (device if device != "" else "未命名设备")))
+ PoseReceiver.client_disconnected.connect(func(reason): set_status("Android 设备断开:%s" % reason))
+ AppConfig.changed.connect(func(): pass)
+
+ goto_main_menu()
+
+
+func _build_screens() -> void:
+ _menu = MainMenuScreen.new(_on_start_session, goto_about)
+ _screen_host.add_child(_menu)
+
+ _game = GameScreen.new(goto_main_menu, _on_session_finished)
+ _game.visible = false
+ _screen_host.add_child(_game)
+
+ _result = ResultScreen.new(_on_retry, goto_main_menu, goto_about)
+ _result.visible = false
+ _screen_host.add_child(_result)
+
+ _about = AboutScreen.new(goto_main_menu)
+ _about.visible = false
+ _screen_host.add_child(_about)
+
+
+func _show_only(target: Control) -> void:
+ for screen in [_menu, _game, _result, _about]:
+ if screen != null:
+ screen.visible = screen == target
+
+
+func goto_main_menu() -> void:
+ _show_only(_menu)
+
+
+func goto_about() -> void:
+ _show_only(_about)
+
+
+func _on_start_session(song: Object, motion: Object, model: Object) -> void:
+ _show_only(_game)
+ _game.prepare(song, motion, model)
+ set_status("准备跟练:%s" % (motion.name if motion != null else "未选择动作"))
+
+
+func _on_session_finished(summary: Dictionary, source: Object, asset_name: String) -> void:
+ _result.show_summary(summary, source, asset_name)
+ _show_only(_result)
+ set_status("结算完成:%s | 平均 %.0f 分" % [str(summary.get("grade", "D")), float(summary.get("average_score", 0.0))])
+
+
+func _on_retry() -> void:
+ # 回到游戏页并重新准备上一轮的动作
+ _show_only(_game)
+
+
+func set_status(text: String) -> void:
+ if _status_label != null:
+ _status_label.text = text
diff --git a/src/ui/ui_kit.gd b/src/ui/ui_kit.gd
new file mode 100644
index 0000000..058104f
--- /dev/null
+++ b/src/ui/ui_kit.gd
@@ -0,0 +1,144 @@
+class_name UiKit
+extends RefCounted
+
+## 轻量 UI 构件工厂。
+##
+## 本项目的界面**全部用代码构建**,不手写 .tscn:
+## .tscn 是带 uid/节点路径的手写格式,在没有编辑器的情况下极易出错且难以验证;
+## 用代码构建可以随 GDScript 一起被无头导入与单元测试覆盖。
+## scenes/main.tscn 因此只保留一个挂载 main.gd 的根节点。
+
+const COLOR_BG := Color("1b1f2a")
+const COLOR_PANEL := Color("232838")
+const COLOR_TEXT := Color("e8ecf5")
+const COLOR_MUTED := Color("9aa4b8")
+const COLOR_ACCENT := Color("4ea1ff")
+const COLOR_GOOD := Color("7ae582")
+const COLOR_BAD := Color("ff6b6b")
+
+const FONT_TITLE := 30
+const FONT_HEADING := 20
+const FONT_BODY := 15
+const FONT_SMALL := 13
+
+
+static func apply_root_theme(root: Control) -> void:
+ root.add_theme_color_override("font_color", COLOR_TEXT)
+
+
+static func panel(background: Color = COLOR_PANEL, radius: int = 10) -> PanelContainer:
+ var style := StyleBoxFlat.new()
+ style.bg_color = background
+ style.corner_radius_top_left = radius
+ style.corner_radius_top_right = radius
+ style.corner_radius_bottom_left = radius
+ style.corner_radius_bottom_right = radius
+ style.content_margin_left = 14
+ style.content_margin_right = 14
+ style.content_margin_top = 10
+ style.content_margin_bottom = 10
+ var node := PanelContainer.new()
+ node.add_theme_stylebox_override("panel", style)
+ return node
+
+
+static func label(text: String, size: int = FONT_BODY, color: Color = COLOR_TEXT) -> Label:
+ var node := Label.new()
+ node.text = text
+ node.add_theme_font_size_override("font_size", size)
+ node.add_theme_color_override("font_color", color)
+ return node
+
+
+static func title(text: String) -> Label:
+ return label(text, FONT_TITLE, COLOR_TEXT)
+
+
+static func heading(text: String) -> Label:
+ return label(text, FONT_HEADING, COLOR_TEXT)
+
+
+static func muted(text: String, size: int = FONT_SMALL) -> Label:
+ var node := label(text, size, COLOR_MUTED)
+ node.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
+ return node
+
+
+static func button(text: String, on_pressed: Callable = Callable()) -> Button:
+ var node := Button.new()
+ node.text = text
+ node.custom_minimum_size = Vector2(150, 38)
+ if on_pressed.is_valid():
+ node.pressed.connect(on_pressed)
+ return node
+
+
+static func hbox(separation: int = 10) -> HBoxContainer:
+ var node := HBoxContainer.new()
+ node.add_theme_constant_override("separation", separation)
+ return node
+
+
+static func vbox(separation: int = 8) -> VBoxContainer:
+ var node := VBoxContainer.new()
+ node.add_theme_constant_override("separation", separation)
+ return node
+
+
+static func spacer(size: int = 8) -> Control:
+ var node := Control.new()
+ node.custom_minimum_size = Vector2(0, size)
+ return node
+
+
+static func expander() -> Control:
+ var node := Control.new()
+ node.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ node.size_flags_vertical = Control.SIZE_EXPAND_FILL
+ return node
+
+
+static func separator() -> HSeparator:
+ return HSeparator.new()
+
+
+static func progress_bar(value: float = 0.0) -> ProgressBar:
+ var node := ProgressBar.new()
+ node.min_value = 0.0
+ node.max_value = 100.0
+ node.value = value
+ node.show_percentage = false
+ node.custom_minimum_size = Vector2(0, 18)
+ return node
+
+
+static func option_button(items: PackedStringArray, on_selected: Callable = Callable()) -> OptionButton:
+ var node := OptionButton.new()
+ for item in items:
+ node.add_item(item)
+ if on_selected.is_valid():
+ node.item_selected.connect(on_selected)
+ return node
+
+
+static func line_edit(placeholder: String, text: String = "") -> LineEdit:
+ var node := LineEdit.new()
+ node.placeholder_text = placeholder
+ node.text = text
+ node.custom_minimum_size = Vector2(320, 34)
+ return node
+
+
+static func rich_text() -> RichTextLabel:
+ var node := RichTextLabel.new()
+ node.bbcode_enabled = true
+ node.fit_content = false
+ node.scroll_active = true
+ node.size_flags_vertical = Control.SIZE_EXPAND_FILL
+ node.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ return node
+
+
+static func status_chip(text: String, color: Color) -> Label:
+ var node := label(text, FONT_SMALL, color)
+ return node