Files
agame/tests/test_net_replication.gd
T
2026-10-02 09:32:11 +08:00

223 lines
9.3 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@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]
)
## 片段在 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