可复用玩家状态机

This commit is contained in:
2026-09-23 16:39:12 +08:00
parent 6355ac41a5
commit 40691e81b6
7 changed files with 291 additions and 130 deletions
+65 -19
View File
@@ -1,19 +1,23 @@
@tool
extends McpTestSuite
## 回归测试:Player 的同步配置与动作库必须真的被解析。
## 回归测试:Player 的同步配置、动作库,以及「移动 / 表现」的组件边界。
##
## 两处典型的「静默失效」:
## 1. player_sync.tres 键名写错 / replication_mode 落到 NEVER
## → 远端玩家僵在原地,而且不打任何错误。
## 2. 动作库是从 .glb 拷进来的,track 路径带一层 character-male-e/ 前缀。
## 一旦模型换名、层级变动、蒙皮骨骼改名,AnimationPlayer 会「有片段但什么都播不出来」,
## 同样不报错。
## 三处典型的「静默失效」——都表现为「人站着不动」且不打任何错误:
## 1. player_sync.tres 键名写错 / replication_mode 落到 NEVER → 远端收不到字段;
## 2. track 路径带一层模型目录名前缀,换模型/改层级后整组失效;
## 3. glb 片段的 loop_mode 默认是 NONE,idle/walk/sprint 播一遍就定在最后一帧。
## (loop 由 PlayerAnimator._ready() 在运行时补,编辑器测试覆盖不到,靠实机验证。)
const CONFIG_PATH := "res://character/player/player_sync.tres"
const PLAYER_SCENE_PATH := "res://character/player/player.tscn"
## 从 player.gd 抽出来的动画状态机。控制器只通过它间接驱动动画。
const ANIMATOR_PATH := "Body/PlayerAnimator"
const ANIMATOR_SCRIPT := "res://character/player/player_animator.gd"
## 真正干活的动画器。PlayerAnimator 没接线时按这个名字在自己兄弟子树里找它。
const ANIM_PLAYER_PATH := "Body/character-male-e2/AnimationPlayer"
## player.gd 的 ANIM_CLIPS 写死了这几个名字,少一个对应状态就会静默不动。
## player_animator.gd 的 ANIM_CLIPS 写死了这几个名字,少一个对应状态就会静默不动。
const REQUIRED_CLIPS: Array[String] = ["idle", "walk", "sprint", "jump", "fall"]
## 必须被复制到远端的字段。远端物理与输入都关着,全靠这几个值回放。
@@ -21,8 +25,8 @@ const REQUIRED_SYNCED: Array[String] = [
".:position",
"Body:rotation",
".:health",
".:anim_state",
".:anim_speed",
"Body/PlayerAnimator:anim_state",
"Body/PlayerAnimator:anim_speed",
]
@@ -54,7 +58,7 @@ func test_config_replication_mode_is_always() -> void:
assert_eq(
config.property_get_replication_mode(path),
SceneReplicationConfig.REPLICATION_MODE_ALWAYS,
"%s 的 replication_mode 不是 ALWAYS(改为 NEVER 会静默断掉同步)" % path
"%s 的 replication_mode 不是 ALWAYS(改成 NEVER 会静默断掉同步)" % path
)
@@ -76,6 +80,49 @@ func test_player_scene_wires_synchronizer() -> void:
)
## 组件边界:动画状态机必须是挂在 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:
@@ -83,7 +130,7 @@ func test_required_animation_clips_exist() -> void:
for clip in REQUIRED_CLIPS:
assert_true(
anim_player.has_animation(clip),
"动作库里缺少片段 %s(player.gd 的 ANIM_CLIPS 会指向不存在的片段)" % clip
"动作库里缺少片段 %s(PlayerAnimator 的 ANIM_CLIPS 会指向不存在的片段)" % clip
)
@@ -98,7 +145,6 @@ func test_animation_track_paths_resolve() -> void:
if root == null:
return
# 逐帧验证 5 个要用到的片段,任何一个 track 断了都会让该动作失效
for clip in REQUIRED_CLIPS:
var anim := anim_player.get_animation(clip)
if anim == null:
@@ -123,12 +169,6 @@ func test_animation_track_paths_resolve() -> void:
)
## 直接用 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:
@@ -138,6 +178,12 @@ func _find_animation_player() -> AnimationPlayer:
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)