238 lines
10 KiB
GDScript
238 lines
10 KiB
GDScript
@tool
|
||
extends McpTestSuite
|
||
## 回归测试:Player 的同步配置、动作库,以及「移动 / 表现」的组件边界。
|
||
##
|
||
## 三处典型的「静默失效」——都表现为「人站着不动」且不打任何错误:
|
||
## 1. player_sync.tres 键名写错 / replication_mode 落到 NEVER → 远端收不到字段;
|
||
## 2. track 路径带一层模型目录名前缀,换模型/改层级后整组失效;
|
||
## 3. glb 片段的 loop_mode 默认是 NONE,idle/walk/crouch/run/sneak 播一遍就定在最后一帧。
|
||
## (loop 由 PlayerAnimator._ready() 在运行时补,编辑器测试覆盖不到,靠实机验证。)
|
||
|
||
const CONFIG_PATH := "res://character/player/player_sync.tres"
|
||
const PLAYER_SCENE_PATH := "res://character/player/player.tscn"
|
||
|
||
## 从 player.gd 抽出来的动画状态机。控制器只通过它间接驱动动画。
|
||
## 表现层脚本就直接挂在 AnimationPlayer 上,所以这两条路径指向同一个节点。
|
||
const ANIMATOR_PATH := "Body/AnimationPlayer"
|
||
const ANIMATOR_SCRIPT := "res://character/player/player_animator.gd"
|
||
## 真正干活的动画器。表现层脚本挂在它身上。
|
||
const ANIM_PLAYER_PATH := "Body/AnimationPlayer"
|
||
|
||
## player_animator.gd 的 ANIM_CLIPS 写死了这几个名字,少一个对应状态就会静默不动。
|
||
const REQUIRED_CLIPS: Array[String] = ["idle", "walk", "crouch", "run", "sneak", "jump", "fall"]
|
||
|
||
## CROUCH / SNEAK 两个状态唯一的入口 —— 控制器读的就是这个动作。
|
||
const CROUCH_ACTION := "crouch"
|
||
|
||
## 必须被复制到远端的字段。远端物理与输入都关着,全靠这几个值回放。
|
||
const REQUIRED_SYNCED: Array[String] = [
|
||
".:position",
|
||
"Body:rotation",
|
||
".:health",
|
||
"Body/AnimationPlayer:anim_state",
|
||
"Body/AnimationPlayer: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
|
||
)
|
||
|
||
|
||
## 组件边界:动画状态机必须是挂在 Body 下的节点,而不是塞回 player.gd。
|
||
## 有人把 anim_state / anim_speed 挪回控制器时,这条会失败。
|
||
func test_player_scene_has_animator_component() -> void:
|
||
var player := _instantiate_player()
|
||
if player == null:
|
||
return
|
||
var animator := player.get_node_or_null(ANIMATOR_PATH)
|
||
assert_true(animator != null, "Player 下缺少 %s(动画状态机应独立于 player.gd)" % ANIMATOR_PATH)
|
||
var player_script: Script = player.get_script()
|
||
assert_true(player_script != null, "Player 没有挂脚本")
|
||
if player_script != null:
|
||
var member_names: Array[String] = []
|
||
for member in player_script.get_script_property_list():
|
||
member_names.append(str(member.get("name")))
|
||
assert_false(member_names.has("anim_state"), "anim_state 应该留在 PlayerAnimator 上,不该回到 player.gd")
|
||
assert_false(member_names.has("anim_speed"), "anim_speed 应该留在 PlayerAnimator 上,不该回到 player.gd")
|
||
if animator == null:
|
||
return
|
||
var script: Script = animator.get_script()
|
||
assert_true(script != null, "%s 没有挂脚本" % ANIMATOR_PATH)
|
||
if script != null:
|
||
assert_eq(script.resource_path, ANIMATOR_SCRIPT, "%s 挂的不是动画状态机脚本" % ANIMATOR_PATH)
|
||
|
||
|
||
## PlayerAnimator 没接线时在父节点的子节点里按类型找 AnimationPlayer。
|
||
## 编辑器里跑不了它的 _ready()(非 @tool 脚本),所以这里退一步、用同样的规则找一遍:
|
||
## 改名或挪位置会让这条失败。
|
||
func test_animator_auto_find_target_exists() -> void:
|
||
var player := _instantiate_player()
|
||
if player == null:
|
||
return
|
||
var body := player.get_node_or_null("Body")
|
||
assert_true(body != null, "Player 下缺少 Body")
|
||
if body == null:
|
||
return
|
||
var found := body.find_child("AnimationPlayer", true, false) as AnimationPlayer
|
||
assert_true(found != null, "Body 子树里找不到名为 AnimationPlayer 的节点,PlayerAnimator 的自动接线会失败")
|
||
assert_true(
|
||
player.get_node_or_null(ANIM_PLAYER_PATH) != null,
|
||
"动画器不在预期路径 %s" % ANIM_PLAYER_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(
|
||
_find_clip(anim_player, clip) != null,
|
||
"动作库里缺少片段 %s(PlayerAnimator 的 ANIM_CLIPS 会指向不存在的片段)" % clip
|
||
)
|
||
|
||
|
||
## crouch 动作是 CROUCH / SNEAK 唯一的入口;动作没了按 Ctrl 没反应,而且不报错。
|
||
## 编辑器进程的 InputMap 只带内建动作,得先按引擎启动那套加载一次,否则这条必然假红。
|
||
func test_crouch_action_exists() -> void:
|
||
InputMap.load_from_project_settings()
|
||
assert_true(InputMap.has_action(CROUCH_ACTION), "InputMap 里没有 %s 动作" % CROUCH_ACTION)
|
||
if not InputMap.has_action(CROUCH_ACTION):
|
||
return
|
||
var bound_to_ctrl := false
|
||
for event in InputMap.action_get_events(CROUCH_ACTION):
|
||
if event is InputEventKey and (event as InputEventKey).keycode == KEY_CTRL:
|
||
bound_to_ctrl = true
|
||
assert_true(bound_to_ctrl, "%s 没有绑定 Ctrl" % CROUCH_ACTION)
|
||
|
||
|
||
## 片段是从 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
|
||
|
||
for clip in REQUIRED_CLIPS:
|
||
var anim := _find_clip(anim_player, 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]
|
||
)
|
||
|
||
|
||
## 入口场景必须是开始菜单 —— 否则启动就直接落在世界里,还顶着 Net 那个调试面板。
|
||
## 这条防的是「有人把 run/main_scene 改回世界」。
|
||
func test_main_scene_is_the_menu() -> void:
|
||
var configured := str(ProjectSettings.get_setting("application/run/main_scene", ""))
|
||
assert_true(not configured.is_empty(), "project.godot 没设 application/run/main_scene")
|
||
if configured.is_empty():
|
||
return
|
||
# 存的是 uid 也可能是 res:// 路径,两种都认
|
||
var path := configured
|
||
if configured.begins_with("uid://"):
|
||
path = ResourceUID.get_id_path(ResourceUID.text_to_id(configured))
|
||
assert_true(path.ends_with("ui/main_menu.tscn"),
|
||
"入口场景是 %s,不是开始菜单 —— 启动会直接进世界" % path)
|
||
|
||
|
||
## 片段在 AnimationPlayer 里按「库名/片段名」存(导入时每个 glb 各自成一个库),
|
||
## 短名 has_animation() / get_animation() 一律查不到 —— 拿短名断言会假红,必须按库扫一遍。
|
||
func _find_clip(anim_player: AnimationPlayer, clip: String) -> Animation:
|
||
for lib_name in anim_player.get_animation_library_list():
|
||
var lib := anim_player.get_animation_library(lib_name)
|
||
if lib != null and lib.has_animation(clip):
|
||
return lib.get_animation(clip)
|
||
return null
|
||
|
||
|
||
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
|
||
|
||
|
||
## 直接用 load() 拿到的是编辑器进程里的内存副本 —— 外部刚改过磁盘时它可能还是旧的,
|
||
## 测试会因此假绿或假红。这里强制按磁盘重读,断言的是文件本身。
|
||
func _load_config() -> SceneReplicationConfig:
|
||
return ResourceLoader.load(CONFIG_PATH, "", ResourceLoader.CACHE_MODE_REPLACE) as SceneReplicationConfig
|
||
|
||
|
||
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
|