@tool extends McpTestSuite ## 回归测试:抱起物品系统的契约。 ## ## 谁能被抱由**物品自己**决定(挂 carryable.gd + 进 carryable 组),玩家只管找/抱/扔; ## 抱持偏移和投掷速度也写在物品上。联机只同步 `carrier_peer` 一个整数 —— ## 抱着的时候位置不走同步,每端都从持有者的抱持点算出来,这几条契约坏了联机就会「各看各的」。 const PLAYER_SCENE_PATH := "res://character/player/player.tscn" const ITEM_SCENE_PATH := "res://world/items/carryable_box.tscn" const CARRY_ACTION := "carry" const THROW_ACTION := "throw_carry" const HOLDER_PEER := 7 func suite_name() -> String: return "carry" ## 编辑器进程里的 InputMap 只有内建动作,项目动作要手动 load 一次才在 func _ensure_input_map() -> void: InputMap.load_from_project_settings() ## 把节点塞进场景树,让 _ready() 真的跑起来;track() 负责回收 func _into_tree(node: Node) -> Node: var editor := Engine.get_singleton("EditorInterface") var root: Node = null if editor != null: root = editor.get_edited_scene_root() if root == null: root = Engine.get_main_loop().root root.add_child(node) track(node) return node func _spawn_item() -> Carryable: var packed := load(ITEM_SCENE_PATH) as PackedScene if packed == null: return null return _into_tree(packed.instantiate()) as Carryable ## 造一个「假持有者」:玩家节点在世界里以 peer id 命名、进 net_player 组,抱持点挂在相机架上 func _fake_holder() -> Node3D: var holder := Node3D.new() holder.name = str(HOLDER_PEER) holder.add_to_group(Carryable.PLAYER_GROUP) var rig := Node3D.new() rig.name = "CameraRig" holder.add_child(rig) var socket := Marker3D.new() socket.name = "CarrySocket" socket.position = Vector3(0.0, 1.0, -2.0) rig.add_child(socket) return _into_tree(holder) as Node3D func test_carry_actions_exist() -> void: _ensure_input_map() # 动作没了按键就完全没反应,而且不报错 —— 这种静默失效必须由测试兜住 assert_true(InputMap.has_action(CARRY_ACTION), "缺少 %s 动作,左键抱不起来" % CARRY_ACTION) assert_true(InputMap.has_action(THROW_ACTION), "缺少 %s 动作,长按 Q 扔不出去" % THROW_ACTION) func test_player_scene_has_carry_nodes() -> void: var packed := load(PLAYER_SCENE_PATH) as PackedScene assert_true(packed != null, "%s 未能加载" % PLAYER_SCENE_PATH) if packed == null: return var player := packed.instantiate() track(player) var socket := player.get_node_or_null("CameraRig/CarrySocket") assert_true(socket is Marker3D, "CameraRig 下缺少 CarrySocket(Marker3D)—— 物品没有抱持点") var arms := player.get_node_or_null("Body/CarryArms") assert_true(arms != null, "Body 下缺少 CarryArms 节点") if arms != null: assert_true(arms.get_script() != null and str(arms.get_script().resource_path).ends_with("carry_arms.gd"), "Body/CarryArms 没挂 carry_arms.gd,胳膊不会抱东西") ## 第一人称要看得见自己的胳膊。 ## player.gd 不是 @tool,在编辑器里是 placeholder、方法调不动,所以这里查两件事: ## 代码里确实按 `arm_` 前缀把胳膊留下来,且模型里真的有这些独立 mesh(换模型改名会红)。 func test_first_person_keeps_arms_visible() -> void: var file := FileAccess.open("res://character/player/player.gd", FileAccess.READ) assert_true(file != null, "读不到 player.gd") if file == null: return var source := file.get_as_text() assert_true(source.contains("_hide_body_keep_arms"), "player.gd 里没有「第一人称保留胳膊」的逻辑") assert_true(source.contains("begins_with(\"arm_\")"), "藏身体时没有按 arm_ 前缀把胳膊留下来,第一人称会看不到自己的手") var packed := load(PLAYER_SCENE_PATH) as PackedScene if packed == null: return var player := packed.instantiate() track(player) var skeleton := player.get_node_or_null("Body/mouse/MouseRig/Skeleton3D") assert_true(skeleton != null, "找不到骨架,模型结构变了?") if skeleton == null: return var arms := 0 var others := 0 for child in skeleton.get_children(): var mesh := child as MeshInstance3D if mesh == null: continue if str(mesh.name).begins_with("arm_"): arms += 1 else: others += 1 assert_true(arms > 0, "模型里没有 arm_* 网格,第一人称看不到手") assert_true(others > 0, "模型里没有别的部位网格,藏身体就无从谈起") func test_example_item_is_carryable() -> void: var item := _spawn_item() assert_true(item != null, "%s 实例化失败 —— 可抱物品的示例没了" % ITEM_SCENE_PATH) if item == null: return assert_true(item.is_in_group(Carryable.GROUP), "可抱物品没进 %s 组,玩家就找不到它" % Carryable.GROUP) assert_true(item.get_node_or_null("Collision") is CollisionShape3D, "示例物品没有碰撞体,扔出去会穿地") ## 联机靠 Sync 复制 position / rotation / carrier_peer;漏了 carrier_peer 别人就不知道被抱走了。 func test_item_sync_config_covers_carry_state() -> void: var item := _spawn_item() if item == null: return var sync := item.get_node_or_null("Sync") as MultiplayerSynchronizer assert_true(sync != null, "示例物品没有 MultiplayerSynchronizer,联机看不到别人抱走它") if sync == null or sync.replication_config == null: return var paths: Array[String] = [] for path in sync.replication_config.get_properties(): paths.append(str(path)) for required in [".:carrier_peer", ".:position", ".:rotation"]: assert_true(paths.has(required), "同步配置里少了 %s(实际 %s)" % [required, paths]) func test_carry_freezes_body_and_restores_on_release() -> void: var item := _spawn_item() if item == null: return var layer := item.collision_layer assert_true(item.can_be_carried(), "新物品应该是可以抱的") item.request_pickup(HOLDER_PEER) assert_eq(item.carrier_peer, HOLDER_PEER, "抱起请求没记到 carrier_peer 上") assert_true(not item.can_be_carried(), "抱起来之后不该还能被别人抱走") assert_true(item.freeze, "抱起来要冻结物理,否则会和抱着它的人打架") assert_eq(item.collision_layer, 0, "抱起来要清掉碰撞层,否则物品会把玩家顶飞") item.request_release(Vector3(1.0, 2.0, 3.0)) assert_eq(item.carrier_peer, 0, "放下之后 carrier_peer 要清零") assert_true(item.can_be_carried(), "放下之后应该又能被抱") assert_true(not item.freeze, "单机 / 服务器端放下要恢复物理模拟") assert_eq(item.collision_layer, layer, "放下要恢复原来的碰撞层") assert_true(item.linear_velocity.is_equal_approx(Vector3(1.0, 2.0, 3.0)), "放下时要继承扔出去的速度,实际 %s" % item.linear_velocity) ## 抱着的时候位置不走同步:每端都从持有者的抱持点算。这里用一个假持有者验这件事。 func test_carried_item_follows_holder_socket() -> void: var item := _spawn_item() var holder := _fake_holder() if item == null or holder == null: return item.request_pickup(HOLDER_PEER) item._physics_process(0.016) var socket := holder.get_node("CameraRig/CarrySocket") as Node3D var expected := socket.global_position + socket.global_basis * item.hold_offset assert_true(item.global_position.distance_to(expected) < 0.001, "物品没跟住持有者的抱持点:实际 %s,期望 %s" % [item.global_position, expected]) # 松开之后就不该再跟了 item.request_release(Vector3.ZERO) var after_release := item.global_position holder.position += Vector3(10.0, 0.0, 0.0) item._physics_process(0.016) assert_true(item.global_position.distance_to(after_release) < 0.001, "已经放手了还在跟持有者跑") func test_throw_speed_scales_with_charge() -> void: var item := _spawn_item() if item == null: return assert_true(absf(item.throw_speed(0.0) - item.throw_speed_min) < 0.001, "蓄力为 0 时应该是最低出手速度") assert_true(absf(item.throw_speed(1.0) - item.throw_speed_max) < 0.001, "蓄满时应该是最高出手速度") assert_true(item.throw_speed(0.5) > item.throw_speed_min and item.throw_speed(0.5) < item.throw_speed_max, "出手速度应该随蓄力单调变化") assert_true(item.throw_speed(9.0) <= item.throw_speed_max, "蓄力超出范围要被夹住") ## 抱起姿势必须真的把胳膊伸到身体**前方**(模型正面是本地 +Z,尾巴在 -Z 那一侧)。 ## 参数写反或者骨骼名写错时,这里会红 —— 光看代码看不出胳膊朝哪边伸。 func test_carry_pose_reaches_forward() -> void: var packed := load(PLAYER_SCENE_PATH) as PackedScene if packed == null: return var player := _into_tree(packed.instantiate()) var arms := player.get_node_or_null("Body/CarryArms") as CarryArms var skeleton := player.get_node_or_null("Body/mouse/MouseRig/Skeleton3D") as Skeleton3D assert_true(arms != null and skeleton != null, "玩家场景里缺 CarryArms 或骨架") if arms == null or skeleton == null: return var hand := skeleton.find_bone("arm_l_03") assert_true(hand >= 0, "骨架里没有 arm_l_03,胳膊姿势会不完整") if hand < 0: return var before := skeleton.get_bone_global_pose(hand).origin arms.set_carrying(true, 0.0) # 直接推进两次让它混合到位(测试里没有引擎帧) arms._process(1.0) arms._process(1.0) var after := skeleton.get_bone_global_pose(hand).origin assert_true(after.z > before.z + 0.5, "抱起来时手要伸到身体前方:抱之前 z=%.2f,抱之后 z=%.2f" % [before.z, after.z]) assert_true(after.y > before.y, "抱起来时手要抬到身前的高度:抱之前 y=%.2f,抱之后 y=%.2f" % [before.y, after.y])