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
+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