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