@tool extends McpTestSuite ## 回归测试:Player 的同步配置与动作库必须真的被解析。 ## ## 两处典型的「静默失效」: ## 1. player_sync.tres 键名写错 / replication_mode 落到 NEVER ## → 远端玩家僵在原地,而且不打任何错误。 ## 2. 动作库是从 .glb 拷进来的,track 路径带一层 character-male-e/ 前缀。 ## 一旦模型换名、层级变动、蒙皮骨骼改名,AnimationPlayer 会「有片段但什么都播不出来」, ## 同样不报错。 const CONFIG_PATH := "res://character/player/player_sync.tres" const PLAYER_SCENE_PATH := "res://character/player/player.tscn" const ANIM_PLAYER_PATH := "Body/character-male-e2/AnimationPlayer" ## player.gd 的 ANIM_CLIPS 写死了这几个名字,少一个对应状态就会静默不动。 const REQUIRED_CLIPS: Array[String] = ["idle", "walk", "sprint", "jump", "fall"] ## 必须被复制到远端的字段。远端物理与输入都关着,全靠这几个值回放。 const REQUIRED_SYNCED: Array[String] = [ ".:position", "Body:rotation", ".:health", ".:anim_state", ".: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 ) 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( anim_player.has_animation(clip), "动作库里缺少片段 %s(player.gd 的 ANIM_CLIPS 会指向不存在的片段)" % clip ) ## 片段是从 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 # 逐帧验证 5 个要用到的片段,任何一个 track 断了都会让该动作失效 for clip in REQUIRED_CLIPS: var anim := anim_player.get_animation(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] ) ## 直接用 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: 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 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