feat(win): T-WIN-001 Godot 工程骨架 + 4 个 autoload
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
|
||||
<rect width="128" height="128" rx="24" fill="#1b1f2a"/>
|
||||
<g stroke="#4ea1ff" stroke-width="6" stroke-linecap="round" fill="none">
|
||||
<path d="M64 24v14"/>
|
||||
<path d="M64 38 L44 60 M64 38 L84 60"/>
|
||||
<path d="M44 60 L36 86 M84 60 L92 86"/>
|
||||
<path d="M64 38 L64 78"/>
|
||||
<path d="M64 78 L52 104 M64 78 L76 104"/>
|
||||
</g>
|
||||
<g fill="#ffd166">
|
||||
<circle cx="64" cy="24" r="7"/>
|
||||
<circle cx="44" cy="60" r="5"/>
|
||||
<circle cx="84" cy="60" r="5"/>
|
||||
<circle cx="36" cy="86" r="5"/>
|
||||
<circle cx="92" cy="86" r="5"/>
|
||||
<circle cx="64" cy="78" r="5"/>
|
||||
<circle cx="52" cy="104" r="5"/>
|
||||
<circle cx="76" cy="104" r="5"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 743 B |
@@ -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
|
||||
@@ -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")
|
||||
@@ -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://<windows-host>: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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://csmakru5y6laa
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://dk0t5uraxyawn
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dkxdsbrbi7hsx
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cyyv3nvnqlqma
|
||||
+116
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user