302 lines
9.5 KiB
GDScript
302 lines
9.5 KiB
GDScript
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
|