可复用玩家状态机
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user