Files
opendance-windows/src/ui/main_menu.gd
T

302 lines
9.5 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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