From 85a25682667e292f736ae81d4de2df25d753f78b Mon Sep 17 00:00:00 2001 From: zijunchiang Date: Mon, 14 Sep 2026 20:04:54 +0800 Subject: [PATCH] =?UTF-8?q?feat(win):=20T-WIN-001=20Godot=20=E5=B7=A5?= =?UTF-8?q?=E7=A8=8B=E9=AA=A8=E6=9E=B6=20+=204=20=E4=B8=AA=20autoload?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- icon.svg | 20 ++++ project.godot | 55 ++++++++++ scenes/main.tscn | 12 +++ src/autoload/app_config.gd | 114 ++++++++++++++++++++ src/autoload/app_config.gd.uid | 1 + src/autoload/catalog_store.gd | 169 ++++++++++++++++++++++++++++++ src/autoload/catalog_store.gd.uid | 1 + src/autoload/pose_receiver.gd | 110 +++++++++++++++++++ src/autoload/pose_receiver.gd.uid | 1 + src/autoload/score_service.gd | 124 ++++++++++++++++++++++ src/autoload/score_service.gd.uid | 1 + src/ui/main.gd | 116 ++++++++++++++++++++ src/ui/ui_kit.gd | 144 +++++++++++++++++++++++++ 13 files changed, 868 insertions(+) create mode 100644 icon.svg create mode 100644 project.godot create mode 100644 scenes/main.tscn create mode 100644 src/autoload/app_config.gd create mode 100644 src/autoload/app_config.gd.uid create mode 100644 src/autoload/catalog_store.gd create mode 100644 src/autoload/catalog_store.gd.uid create mode 100644 src/autoload/pose_receiver.gd create mode 100644 src/autoload/pose_receiver.gd.uid create mode 100644 src/autoload/score_service.gd create mode 100644 src/autoload/score_service.gd.uid create mode 100644 src/ui/main.gd create mode 100644 src/ui/ui_kit.gd 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