149 lines
5.5 KiB
GDScript
149 lines
5.5 KiB
GDScript
@tool
|
||
extends McpTestSuite
|
||
## 回归测试:Player 的同步配置与动作库必须真的被解析。
|
||
##
|
||
## 两处典型的「静默失效」:
|
||
## 1. player_sync.tres 键名写错 / replication_mode 落到 NEVER
|
||
## → 远端玩家僵在原地,而且不打任何错误。
|
||
## 2. 动作库是从 .glb 拷进来的,track 路径带一层 character-male-e/ 前缀。
|
||
## 一旦模型换名、层级变动、蒙皮骨骼改名,AnimationPlayer 会「有片段但什么都播不出来」,
|
||
## 同样不报错。
|
||
|
||
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_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()
|
||
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()
|
||
assert_true(config != null, "%s 未能加载" % CONFIG_PATH)
|
||
if config == null:
|
||
return
|
||
var paths := config.get_properties()
|
||
assert_gt(paths.size(), 0, "同步字段为空,远端玩家不会移动")
|
||
for path in paths:
|
||
assert_eq(
|
||
config.property_get_replication_mode(path),
|
||
SceneReplicationConfig.REPLICATION_MODE_ALWAYS,
|
||
"%s 的 replication_mode 不是 ALWAYS(改为 NEVER 会静默断掉同步)" % path
|
||
)
|
||
|
||
|
||
func test_player_scene_wires_synchronizer() -> void:
|
||
var player := _instantiate_player()
|
||
if player == null:
|
||
return
|
||
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
|