@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