角色动画

This commit is contained in:
2026-09-23 16:27:02 +08:00
parent 414fcafe1d
commit 6355ac41a5
18 changed files with 3260 additions and 77 deletions
+113 -19
View File
@@ -1,32 +1,51 @@
@tool
extends McpTestSuite
## 回归测试:Player 的同步配置必须真的被解析成条目。
## 回归测试:Player 的同步配置与动作库必须真的被解析。
##
## 这是一处典型的「静默失效」:如果 .tres 里的键名写错,或 replication_mode
## 落到 NEVER,远端玩家会僵在原地不动,而且不打任何错误。用测试钉住它。
## 两处典型的「静默失效」:
## 1. player_sync.tres 键名写错 / replication_mode 落到 NEVER
## → 远端玩家僵在原地,而且不打任何错误。
## 2. 动作库是从 .glb 拷进来的,track 路径带一层 character-male-e/ 前缀。
## 一旦模型换名、层级变动、蒙皮骨骼改名,AnimationPlayer 会「有片段但什么都播不出来」,
## 同样不报错。
const CONFIG_PATH := "res://player/player_sync.tres"
const PLAYER_SCENE_PATH := "res://player/player.tscn"
const CONFIG_PATH := "res://character/player/player_sync.tres"
const PLAYER_SCENE_PATH := "res://character/player/player.tscn"
const ANIM_PLAYER_PATH := "Body/character-male-e2/AnimationPlayer"
## player.gd 的 ANIM_CLIPS 写死了这几个名字,少一个对应状态就会静默不动。
const REQUIRED_CLIPS: Array[String] = ["idle", "walk", "sprint", "jump", "fall"]
## 必须被复制到远端的字段。远端物理与输入都关着,全靠这几个值回放。
const REQUIRED_SYNCED: Array[String] = [
".:position",
"Body:rotation",
".:health",
".:anim_state",
".:anim_speed",
]
func suite_name() -> String:
return "net"
func test_config_has_expected_paths() -> void:
var config := load(CONFIG_PATH) as SceneReplicationConfig
assert_true(config != null, "player_sync.tres 未能加载为 SceneReplicationConfig")
func test_config_has_required_synced_fields() -> void:
var config := _load_config()
assert_true(config != null, "%s 未能加载为 SceneReplicationConfig" % CONFIG_PATH)
if config == null:
return
var paths := config.get_properties()
assert_eq(paths.size(), 2, "同步字段数量不对:%s" % [paths])
assert_true(config.has_property(^".:position"), "缺少 .:position")
assert_true(config.has_property(^"Body:rotation"), "缺少 Body:rotation")
for required in REQUIRED_SYNCED:
assert_true(
config.has_property(NodePath(required)),
"缺少同步字段 %s,实际为 %s" % [required, paths]
)
func test_config_replication_mode_is_always() -> void:
var config := load(CONFIG_PATH) as SceneReplicationConfig
assert_true(config != null, "player_sync.tres 未能加载")
var config := _load_config()
assert_true(config != null, "%s 未能加载" % CONFIG_PATH)
if config == null:
return
var paths := config.get_properties()
@@ -35,20 +54,95 @@ func test_config_replication_mode_is_always() -> void:
assert_eq(
config.property_get_replication_mode(path),
SceneReplicationConfig.REPLICATION_MODE_ALWAYS,
"%s 的 replication_mode 不是 ALWAYS" % path
"%s 的 replication_mode 不是 ALWAYS(改为 NEVER 会静默断掉同步)" % path
)
func test_player_scene_wires_synchronizer() -> void:
var packed := load(PLAYER_SCENE_PATH) as PackedScene
assert_true(packed != null, "player.tscn 未能加载")
if packed == null:
var player := _instantiate_player()
if player == null:
return
var player := packed.instantiate()
track(player)
var sync := player.get_node_or_null("Sync") as MultiplayerSynchronizer
assert_true(sync != null, "Player 下缺少 Sync 节点")
if sync == null:
return
assert_true(sync.replication_config != null, "Sync 没有绑定 SceneReplicationConfig")
assert_eq(sync.root_path, ^"..", "Sync.root_path 应为 .. (相对 Player)")
if sync.replication_config != null:
assert_eq(
sync.replication_config.resource_path,
CONFIG_PATH,
"Sync 绑定的是别的同步配置:%s" % sync.replication_config.resource_path
)
func test_required_animation_clips_exist() -> void:
var anim_player := _find_animation_player()
if anim_player == null:
return
for clip in REQUIRED_CLIPS:
assert_true(
anim_player.has_animation(clip),
"动作库里缺少片段 %s(player.gd 的 ANIM_CLIPS 会指向不存在的片段)" % clip
)
## 片段是从 glb 里拷出来的,track 路径带层级前缀。换模型/改层级后整组 track 会一起失效,
## 而 AnimationPlayer 不报任何错 —— 现象是「人站着不动」。用测试钉住路径真的能解析。
func test_animation_track_paths_resolve() -> void:
var anim_player := _find_animation_player()
if anim_player == null:
return
var root := anim_player.get_node_or_null(anim_player.root_node)
assert_true(root != null, "AnimationPlayer.root_node 解析不到:%s" % anim_player.root_node)
if root == null:
return
# 逐帧验证 5 个要用到的片段,任何一个 track 断了都会让该动作失效
for clip in REQUIRED_CLIPS:
var anim := anim_player.get_animation(clip)
if anim == null:
continue
assert_gt(anim.get_track_count(), 0, "%s 片段没有任何 track" % clip)
for i in anim.get_track_count():
var track_path := anim.track_get_path(i)
# 不能直接 get_node(track_path):路径含 ":骨骼名" 属性部分,get_node 只认节点段
var owner_node := root.get_node_or_null(NodePath(track_path.get_concatenated_names()))
assert_true(
owner_node != null,
"%s 的 track %s 在 %s 下解析不到(动画会静默无效)" % [clip, track_path, anim_player.root_node]
)
if owner_node == null:
continue
# 属性段是骨骼名,模型换皮时要确保骨骼名没变
var bone := str(track_path.get_concatenated_subnames())
if owner_node is Skeleton3D and not bone.is_empty():
assert_true(
(owner_node as Skeleton3D).find_bone(bone) != -1,
"%s 引用了骨骼 %s,但骨架里没有这根骨头(模型换过了?)" % [clip, bone]
)
## 直接用 load() 拿到的是编辑器进程里的内存副本 —— 外部刚改过磁盘时它可能还是旧的,
## 测试会因此假绿或假红。这里强制按磁盘重读,断言的是文件本身。
func _load_config() -> SceneReplicationConfig:
return ResourceLoader.load(CONFIG_PATH, "", ResourceLoader.CACHE_MODE_REPLACE) as SceneReplicationConfig
func _find_animation_player() -> AnimationPlayer:
var player := _instantiate_player()
if player == null:
return null
var anim_player := player.get_node_or_null(ANIM_PLAYER_PATH) as AnimationPlayer
assert_true(anim_player != null, "找不到 %s" % ANIM_PLAYER_PATH)
return anim_player
func _instantiate_player() -> Node:
var packed := load(PLAYER_SCENE_PATH) as PackedScene
assert_true(packed != null, "%s 未能加载" % PLAYER_SCENE_PATH)
if packed == null:
return null
var player := packed.instantiate()
track(player)
return player