T-WIN-013:API 升级与场景选择 - 契约快照同步到 rest-api 0.5.0 / data-models 0.7.0 / database 0.4.0 - catalog_types.gd 新增 SceneConfig(物理/环境/出生点/相机)与 Scene; CatalogBundle 增加 scenes[];from_dict 按契约 7.1 再收敛一次 (缺项默认、越界夹紧、非法枚举回退),与服务端 normalizeSceneConfig 同规则 - 新增 src/scene/scene_runtime.gd:GLB 载入 -> 碰撞体(mesh 三角网 / auto 命名约定 *_col、*_colonly / none 不兜底)-> 场景级重力(Area3D SPACE_OVERRIDE_REPLACE,不改全局 ProjectSettings)-> 风(每物理帧 apply_central_force,阵风正弦叠加,支持 local 坐标系)-> 环境光与雾 -> 出生点与相机(follow/fixed/orbit);无碰撞体时兜底地面 - CatalogStore 增加 scenes/default_scene/find_scene/ensure_scene_cached/ fetch_thumbnail(场景缓存带 sha256 指纹,避免换包后仍用旧 GLB) - 主界面改为 2x2 四个选择器(曲目/动作/模型/场景)+ 场景参数面板 (缩略图 + 重力/摩擦/弹性/碰撞体/风摘要) - 游戏界面载入场景并落地物理,角色落在出生点;场景缺失/下载失败/ 解析失败一律回退内置空场景并提示,不阻塞开始跟练 T-WIN-012:「关于」清单展示当前版本号 - 版本唯一来源为 project.godot 的 application/config/version(v0.2.0) - about_manifest() 输出版本/引擎/契约/协议/地址/隐私清单; 无头测试无 autoload,读取 AppConfig 走 get_node_or_null 容错 - Credits 的单个 CONTRACT_VERSION 常量改为 CONTRACT_VERSIONS 字典 (六项契约各自独立递增),并双向核对 contracts/VERSION T-WIN-014:发布必须是单个完整包 - build/package-release.sh 重写:只产出单个 zip + SHA256SUMS, 清理历史 .partN,超限时告警而非回退分卷;新增源产物与解压后完整性 自检(PE 头 MZ / pck 魔数 GDPC / exe+pck 同目录同名)与体积-上限对比 - deploy/proxy 相关的 Gitea IIS 256MB 上限说明见 opendance-web 测试:tests/test_scene_config.gd(12 例)+ tests/test_about.gd(4 例); 无头测试 687 通过 / 0 失败(10 个套件),verify.sh 全部通过
489 lines
16 KiB
GDScript
489 lines
16 KiB
GDScript
class_name GameScreen
|
||
extends Control
|
||
|
||
## T-WIN-008:游戏界面(3D 角色 + 实时评分 HUD)
|
||
## T-WIN-009:来源信息展示
|
||
## T-WIN-013:场景载入与物理落地(重力 / 风 / 碰撞 / 环境 / 机位)
|
||
|
||
const UiKit = preload("res://src/ui/ui_kit.gd")
|
||
const Scoring = preload("res://src/score/scoring.gd")
|
||
const VrmLoader = preload("res://src/model/vrm_loader.gd")
|
||
const SkeletonRetarget = preload("res://src/anim/skeleton_retarget.gd")
|
||
const SourcePanel = preload("res://src/ui/source_panel.gd")
|
||
const ScenePanel = preload("res://src/ui/scene_panel.gd")
|
||
const SceneRuntime = preload("res://src/scene/scene_runtime.gd")
|
||
const CatalogTypes = preload("res://src/net/catalog_types.gd")
|
||
|
||
enum State { IDLE, PREPARING, RUNNING, FINISHED, FAILED }
|
||
|
||
var state: State = State.IDLE
|
||
var vrm: VrmLoader.VrmModel = null
|
||
var retarget: SkeletonRetarget = null
|
||
var scene_runtime: SceneRuntime = null
|
||
var scene_warning: String = ""
|
||
|
||
var _viewport: SubViewport
|
||
var _camera: Camera3D
|
||
var _world_env: Environment
|
||
var _model_host: Node3D
|
||
var _scene_host: Node3D
|
||
var _score_label: Label
|
||
var _grade_label: Label
|
||
var _combo_label: Label
|
||
var _progress: ProgressBar
|
||
var _status_label: Label
|
||
var _hint_label: Label
|
||
var _source_panel: SourcePanel
|
||
var _scene_panel: ScenePanel
|
||
var _scene_note: Label
|
||
var _start_button: Button
|
||
var _stop_button: Button
|
||
|
||
var _motion: CatalogTypes.Motion = null
|
||
var _song: CatalogTypes.Song = null
|
||
var _model: CatalogTypes.Model = null
|
||
var _scene: CatalogTypes.Scene = null
|
||
var _good_frames: int = 0
|
||
var _total_frames: int = 0
|
||
var _elapsed: float = 0.0
|
||
|
||
var _on_exit: Callable = Callable()
|
||
var _on_finished: Callable = Callable()
|
||
|
||
|
||
func _init(on_exit: Callable = Callable(), on_finished: Callable = Callable()) -> void:
|
||
_on_exit = on_exit
|
||
_on_finished = on_finished
|
||
|
||
|
||
func _ready() -> void:
|
||
set_anchors_preset(Control.PRESET_FULL_RECT)
|
||
name = "GameScreen"
|
||
|
||
var root := UiKit.hbox(14)
|
||
root.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||
root.offset_left = 20
|
||
root.offset_top = 16
|
||
root.offset_right = -20
|
||
root.offset_bottom = -16
|
||
add_child(root)
|
||
|
||
# ---- 左侧:3D 视图 ----
|
||
var view_container := SubViewportContainer.new()
|
||
view_container.stretch = true
|
||
view_container.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
view_container.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||
|
||
_viewport = SubViewport.new()
|
||
_viewport.size = Vector2i(960, 720)
|
||
_viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
|
||
_viewport.transparent_bg = false
|
||
view_container.add_child(_viewport)
|
||
root.add_child(view_container)
|
||
|
||
_env_setup()
|
||
|
||
# ---- 右侧:HUD ----
|
||
var side := UiKit.vbox(10)
|
||
side.custom_minimum_size = Vector2(380, 0)
|
||
root.add_child(side)
|
||
|
||
side.add_child(UiKit.title("评分"))
|
||
|
||
var score_card := UiKit.panel()
|
||
var score_box := UiKit.vbox(4)
|
||
_score_label = UiKit.label("—", 52, UiKit.COLOR_ACCENT)
|
||
_score_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||
score_box.add_child(_score_label)
|
||
_grade_label = UiKit.label("等待开始", UiKit.FONT_BODY, UiKit.COLOR_MUTED)
|
||
_grade_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||
score_box.add_child(_grade_label)
|
||
_combo_label = UiKit.label("连击 0", UiKit.FONT_SMALL, UiKit.COLOR_MUTED)
|
||
_combo_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||
score_box.add_child(_combo_label)
|
||
score_card.add_child(score_box)
|
||
side.add_child(score_card)
|
||
|
||
side.add_child(UiKit.label("动作进度", UiKit.FONT_SMALL, UiKit.COLOR_MUTED))
|
||
_progress = UiKit.progress_bar(0.0)
|
||
side.add_child(_progress)
|
||
|
||
_status_label = UiKit.label("", UiKit.FONT_SMALL, UiKit.COLOR_MUTED)
|
||
_status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
side.add_child(_status_label)
|
||
|
||
_hint_label = UiKit.label("", UiKit.FONT_SMALL, UiKit.COLOR_MUTED)
|
||
_hint_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
side.add_child(_hint_label)
|
||
|
||
_source_panel = SourcePanel.new("动作来源")
|
||
side.add_child(_source_panel)
|
||
|
||
_scene_panel = ScenePanel.new("场景参数")
|
||
side.add_child(_scene_panel)
|
||
|
||
_scene_note = UiKit.label("", UiKit.FONT_SMALL, UiKit.COLOR_MUTED)
|
||
_scene_note.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
_scene_note.visible = false
|
||
side.add_child(_scene_note)
|
||
|
||
side.add_child(UiKit.expander())
|
||
|
||
var actions := UiKit.hbox(8)
|
||
_start_button = UiKit.button("开始跟练", _on_start_pressed)
|
||
_stop_button = UiKit.button("结束本轮", _on_stop_pressed)
|
||
_stop_button.disabled = true
|
||
actions.add_child(_start_button)
|
||
actions.add_child(_stop_button)
|
||
side.add_child(actions)
|
||
side.add_child(UiKit.button("返回主菜单", func(): if _on_exit.is_valid(): _on_exit.call()))
|
||
|
||
PoseReceiver.frame_received.connect(_on_frame_received)
|
||
ScoreService.score_updated.connect(_on_score_updated)
|
||
PoseReceiver.client_connected.connect(func(_d): _refresh_connection_hint())
|
||
PoseReceiver.client_disconnected.connect(func(_r): _refresh_connection_hint())
|
||
|
||
_refresh_connection_hint()
|
||
|
||
|
||
func _env_setup() -> void:
|
||
var world := WorldEnvironment.new()
|
||
var env := Environment.new()
|
||
env.background_mode = Environment.BG_COLOR
|
||
env.background_color = UiKit.COLOR_BG
|
||
env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
|
||
env.ambient_light_color = Color(1, 1, 1)
|
||
env.ambient_light_energy = 0.9
|
||
world.environment = env
|
||
_viewport.add_child(world)
|
||
# 场景的环境参数(环境光 / 雾)直接写进这个 Environment,不另建 WorldEnvironment
|
||
_world_env = env
|
||
|
||
var light := DirectionalLight3D.new()
|
||
light.name = "SceneSun"
|
||
light.rotation_degrees = Vector3(-35, 40, 0)
|
||
light.light_energy = 1.2
|
||
_viewport.add_child(light)
|
||
|
||
_camera = Camera3D.new()
|
||
_camera.position = Vector3(0, 1.35, 2.4)
|
||
_camera.rotation_degrees = Vector3(-6, 0, 0)
|
||
_camera.fov = 40.0
|
||
_viewport.add_child(_camera)
|
||
|
||
_scene_host = Node3D.new()
|
||
_scene_host.name = "SceneHost"
|
||
_viewport.add_child(_scene_host)
|
||
|
||
_model_host = Node3D.new()
|
||
_model_host.name = "ModelHost"
|
||
_viewport.add_child(_model_host)
|
||
|
||
|
||
## 设置本轮要跟练的场景、曲目与动作,并加载模型
|
||
## `scene` 可以为 null(服务端未发布场景)—— 此时用内置空场景,**不阻塞开始跟练**
|
||
func prepare(song: CatalogTypes.Song, motion: CatalogTypes.Motion, model: CatalogTypes.Model, scene: CatalogTypes.Scene = null) -> void:
|
||
_song = song
|
||
_motion = motion
|
||
_model = model
|
||
_scene = scene
|
||
_good_frames = 0
|
||
_total_frames = 0
|
||
_elapsed = 0.0
|
||
state = State.PREPARING
|
||
_progress.value = 0.0
|
||
_score_label.text = "—"
|
||
_grade_label.text = "正在准备…"
|
||
_start_button.disabled = true
|
||
_stop_button.disabled = true
|
||
|
||
_source_panel.show_source(motion.source if motion != null else null, motion.name if motion != null else "")
|
||
_scene_panel.show_scene(scene)
|
||
_set_status("正在载入场景…")
|
||
|
||
_clear_model()
|
||
_clear_scene()
|
||
|
||
var scene_note: String = await _load_scene(scene)
|
||
if scene_note != "":
|
||
scene_warning = scene_note
|
||
|
||
if model == null or model.file_url == "":
|
||
_fail("目录中没有可用模型(未发布任何模型)")
|
||
return
|
||
|
||
_set_status("正在下载并加载模型…")
|
||
var cached: Dictionary = await CatalogStore.ensure_model_cached(model)
|
||
if not cached["ok"]:
|
||
_fail("模型下载失败:%s" % str(cached["error"]))
|
||
return
|
||
|
||
var path := str(cached["path"])
|
||
vrm = VrmLoader.load_from_file(path)
|
||
if not vrm.available or vrm.skeleton == null:
|
||
_fail("模型加载失败:%s" % (vrm.error if vrm.error != "" else "缺少骨骼"))
|
||
return
|
||
|
||
vrm.root.visible = true
|
||
_model_host.add_child(vrm.root)
|
||
|
||
# 角色落在场景出生点(无场景时即世界原点),再按相机模式取景
|
||
_place_character_on_spawn()
|
||
_apply_camera(0.0)
|
||
|
||
retarget = SkeletonRetarget.new(vrm.skeleton, vrm.bones)
|
||
retarget.capture_rest()
|
||
if not retarget.is_bound():
|
||
_fail("骨骼绑定失败,模型可能不受支持")
|
||
return
|
||
|
||
var coverage := vrm.coverage() * 100.0
|
||
_set_status("模型就绪:%s(VRM %s,%d 骨骼,人形覆盖率 %.0f%%)| %s" % [
|
||
model.name, vrm.vrm_version if vrm.vrm_version != "" else "—", vrm.bone_count, coverage,
|
||
scene_runtime.describe() if scene_runtime != null else "无场景",
|
||
])
|
||
if coverage < 60.0:
|
||
_grade_label.text = "骨骼覆盖偏低,动作可能不完整"
|
||
_show_scene_note(scene_warning)
|
||
|
||
state = State.IDLE
|
||
_start_button.disabled = false
|
||
_refresh_connection_hint()
|
||
|
||
|
||
func _show_scene_note(note: String) -> void:
|
||
if _scene_note == null:
|
||
return
|
||
_scene_note.visible = note != ""
|
||
_scene_note.text = "⚠ %s" % note if note != "" else ""
|
||
_scene_note.add_theme_color_override("font_color", UiKit.COLOR_BAD)
|
||
|
||
|
||
# ---------------- 场景(T-WIN-013) ----------------
|
||
|
||
## 载入场景并落地物理。返回非空字符串表示需要提示的降级原因(不阻塞开始跟练)。
|
||
func _load_scene(scene: CatalogTypes.Scene) -> String:
|
||
scene_warning = ""
|
||
var config: CatalogTypes.SceneConfig = null
|
||
if scene != null:
|
||
config = scene.config
|
||
|
||
if scene == null:
|
||
scene_runtime = SceneRuntime.create_empty(config)
|
||
scene_runtime.apply_environment(_world_env)
|
||
_scene_host.add_child(scene_runtime)
|
||
push_warning("[GameScreen] 未选择场景,使用内置空场景")
|
||
return "未选择场景,已使用内置空场景"
|
||
|
||
if not scene.is_usable():
|
||
scene_runtime = SceneRuntime.create_empty(config, scene.name)
|
||
scene_runtime.apply_environment(_world_env)
|
||
_scene_host.add_child(scene_runtime)
|
||
return "场景「%s」缺少 GLB 文件,已回退内置空场景" % scene.name
|
||
|
||
var cached: Dictionary = await CatalogStore.ensure_scene_cached(scene)
|
||
if not cached["ok"]:
|
||
scene_runtime = SceneRuntime.create_empty(config, scene.name)
|
||
scene_runtime.apply_environment(_world_env)
|
||
_scene_host.add_child(scene_runtime)
|
||
return "场景下载失败(%s),已回退内置空场景" % str(cached["error"])
|
||
|
||
scene_runtime = SceneRuntime.load_from_file(str(cached["path"]), config, scene.name)
|
||
scene_runtime.apply_environment(_world_env)
|
||
_scene_host.add_child(scene_runtime)
|
||
# 日志核对:重力 / 风 / 摩擦 / 碰撞体数量(文档 §5.6 第 3 条要求可核对)
|
||
print("[GameScreen] %s" % scene_runtime.describe())
|
||
if scene_runtime.warnings.size() > 0:
|
||
push_warning("[GameScreen] 场景降级:%s" % scene_runtime.warnings_text())
|
||
return scene_runtime.warnings_text()
|
||
return ""
|
||
|
||
|
||
## 角色落到场景出生点(含 yaw 朝向)
|
||
func _place_character_on_spawn() -> void:
|
||
if vrm == null or vrm.root == null:
|
||
return
|
||
var transform := Transform3D.IDENTITY
|
||
if scene_runtime != null:
|
||
transform = scene_runtime.spawn_transform(0)
|
||
vrm.root.transform = transform
|
||
|
||
|
||
## 按场景相机配置摆机位;无场景时退回原来的"框住模型"行为
|
||
func _apply_camera(time_seconds: float) -> void:
|
||
if vrm == null or vrm.root == null:
|
||
return
|
||
if scene_runtime != null:
|
||
var target := scene_runtime.spawn_transform(0).origin
|
||
# 以模型上半身为焦点,避免角色高出镜头
|
||
target.y += 1.0
|
||
scene_runtime.apply_camera(_camera, target, time_seconds)
|
||
return
|
||
_frame_camera_on(vrm.root)
|
||
|
||
|
||
## 让相机框住模型
|
||
func _frame_camera_on(root: Node3D) -> void:
|
||
var aabb := _compute_aabb(root)
|
||
if aabb.size == Vector3.ZERO:
|
||
return
|
||
var height := aabb.size.y
|
||
var center := aabb.get_center()
|
||
_camera.position = center + Vector3(0, 0, maxf(height * 1.5, 2.0))
|
||
_camera.rotation_degrees = Vector3(0, 0, 0)
|
||
# 略微俯视,看向模型上半身
|
||
_camera.position.y = center.y + height * 0.15
|
||
_camera.look_at(center + Vector3(0, height * 0.02, 0), Vector3.UP)
|
||
|
||
|
||
func _compute_aabb(node: Node) -> AABB:
|
||
var result := AABB()
|
||
var first := true
|
||
if node is MeshInstance3D:
|
||
var mesh := (node as MeshInstance3D).get_aabb()
|
||
var world := (node as MeshInstance3D).global_transform
|
||
# 用节点自身变换近似(骨架绑定姿态下足够用于定机位)
|
||
result = world * mesh
|
||
first = false
|
||
for child in node.get_children():
|
||
var child_aabb := _compute_aabb(child)
|
||
if child_aabb.size == Vector3.ZERO:
|
||
continue
|
||
if first:
|
||
result = child_aabb
|
||
first = false
|
||
else:
|
||
result = result.merge(child_aabb)
|
||
return result
|
||
|
||
|
||
func _on_start_pressed() -> void:
|
||
if _motion == null:
|
||
_fail("未选择动作")
|
||
return
|
||
if not PoseReceiver.is_listening():
|
||
PoseReceiver.start()
|
||
if PoseReceiver.connected_client_count() == 0:
|
||
_set_status("尚未连接 Android 辅助 App,已开始计时但暂无可评分数据。")
|
||
|
||
_set_status("正在拉取参考动作序列…")
|
||
_start_button.disabled = true
|
||
|
||
var fetched: Dictionary = await CatalogStore.fetch_sequence(_motion.id)
|
||
if not fetched["ok"]:
|
||
_fail("参考序列拉取失败:%s" % str(fetched["error"]))
|
||
_start_button.disabled = false
|
||
return
|
||
|
||
var sequence = fetched["sequence"]
|
||
ScoreService.start_session(sequence.features, _motion.id, AppConfig.score_tolerance)
|
||
state = State.RUNNING
|
||
_stop_button.disabled = false
|
||
_progress.value = 0.0
|
||
_set_status("跟练进行中:参考 %d 帧(约 %.1f 秒)" % [
|
||
sequence.frame_count(), sequence.duration_ms() / 1000.0,
|
||
])
|
||
|
||
|
||
func _on_stop_pressed() -> void:
|
||
if state != State.RUNNING:
|
||
return
|
||
_finish_session("已手动结束")
|
||
|
||
|
||
func _finish_session(reason: String) -> void:
|
||
var summary := ScoreService.finish()
|
||
state = State.FINISHED
|
||
_stop_button.disabled = true
|
||
_start_button.disabled = false
|
||
_set_status("本轮结束(%s)" % reason)
|
||
if retarget != null:
|
||
retarget.reset()
|
||
if _on_finished.is_valid():
|
||
_on_finished.call(summary, _motion.source if _motion != null else null, _motion.name if _motion != null else "")
|
||
|
||
|
||
func _on_frame_received(frame: Object) -> void:
|
||
if state != State.RUNNING or retarget == null:
|
||
return
|
||
retarget.apply(frame, 4.0 / 3.0)
|
||
_total_frames += 1
|
||
|
||
|
||
func _on_score_updated(result: Dictionary) -> void:
|
||
var score := float(result.get("score", 0.0))
|
||
_score_label.text = "%d" % int(round(score))
|
||
_score_label.add_theme_color_override("font_color", Scoring.grade_color(score))
|
||
_grade_label.text = "%s | %s" % [Scoring.grade(score), Scoring.grade_label(score)]
|
||
_combo_label.text = "连击 %d | 最高连击 %d" % [int(result.get("combo", 0)), int(result.get("max_combo", 0))]
|
||
_progress.value = float(result.get("progress", 0.0)) * 100.0
|
||
if score >= Scoring.COMBO_THRESHOLD:
|
||
_good_frames += 1
|
||
|
||
# 参考序列走完自动结算
|
||
if bool(result.get("progress", 0.0) >= 0.999):
|
||
_finish_session("动作已完成")
|
||
|
||
|
||
func _refresh_connection_hint() -> void:
|
||
var count := PoseReceiver.connected_client_count()
|
||
if count > 0:
|
||
_hint_label.text = "● 已连接 %d 台设备:%s" % [count, AppConfig.listen_url()]
|
||
_hint_label.add_theme_color_override("font_color", UiKit.COLOR_GOOD)
|
||
elif PoseReceiver.is_listening():
|
||
_hint_label.text = "○ 等待 Android 辅助 App 连接 %s" % AppConfig.listen_url()
|
||
_hint_label.add_theme_color_override("font_color", UiKit.COLOR_MUTED)
|
||
else:
|
||
_hint_label.text = "○ 接收服务未启动(将在开始跟练时自动启动 %s)" % AppConfig.listen_url()
|
||
_hint_label.add_theme_color_override("font_color", UiKit.COLOR_MUTED)
|
||
|
||
|
||
func _set_status(text: String) -> void:
|
||
_status_label.text = text
|
||
|
||
|
||
func _fail(message: String) -> void:
|
||
state = State.FAILED
|
||
_set_status(message)
|
||
_grade_label.text = "无法开始"
|
||
_start_button.disabled = true
|
||
_stop_button.disabled = true
|
||
push_warning("[GameScreen] %s" % message)
|
||
|
||
|
||
func _clear_model() -> void:
|
||
if vrm != null:
|
||
if vrm.root != null and is_instance_valid(vrm.root) and vrm.root.get_parent() == _model_host:
|
||
_model_host.remove_child(vrm.root)
|
||
vrm.dispose()
|
||
vrm = null
|
||
retarget = null
|
||
|
||
|
||
func _clear_scene() -> void:
|
||
if scene_runtime != null:
|
||
if is_instance_valid(scene_runtime) and scene_runtime.get_parent() == _scene_host:
|
||
_scene_host.remove_child(scene_runtime)
|
||
scene_runtime.queue_free()
|
||
scene_runtime = null
|
||
scene_warning = ""
|
||
|
||
|
||
## 只在跟练进行中推进相机(orbit 模式需要时间轴),其余时候不动相机
|
||
func _process(delta: float) -> void:
|
||
if scene_runtime == null or vrm == null or _camera == null:
|
||
return
|
||
if scene_runtime.config == null or scene_runtime.config.camera.mode != "orbit":
|
||
return
|
||
_elapsed += delta
|
||
_apply_camera(_elapsed)
|
||
|
||
|
||
func _exit_tree() -> void:
|
||
if PoseReceiver.frame_received.is_connected(_on_frame_received):
|
||
PoseReceiver.frame_received.disconnect(_on_frame_received)
|
||
if ScoreService.score_updated.is_connected(_on_score_updated):
|
||
ScoreService.score_updated.disconnect(_on_score_updated)
|
||
_clear_model()
|
||
_clear_scene()
|