From 9b1d30339fc0b7a4dcbfd5661d4db605b6469eaf Mon Sep 17 00:00:00 2001 From: yvmou_ Date: Thu, 8 Oct 2026 16:40:32 +0800 Subject: [PATCH] =?UTF-8?q?=E7=A7=BB=E9=99=A4=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_carry.gd | 224 -------------------------- tests/test_carry.gd.uid | 1 - tests/test_face.gd | 251 ------------------------------ tests/test_face.gd.uid | 1 - tests/test_net_replication.gd | 237 ---------------------------- tests/test_net_replication.gd.uid | 1 - tests/test_zipline.gd | 224 -------------------------- tests/test_zipline.gd.uid | 1 - 8 files changed, 940 deletions(-) delete mode 100644 tests/test_carry.gd delete mode 100644 tests/test_carry.gd.uid delete mode 100644 tests/test_face.gd delete mode 100644 tests/test_face.gd.uid delete mode 100644 tests/test_net_replication.gd delete mode 100644 tests/test_net_replication.gd.uid delete mode 100644 tests/test_zipline.gd delete mode 100644 tests/test_zipline.gd.uid diff --git a/tests/test_carry.gd b/tests/test_carry.gd deleted file mode 100644 index 82158a7..0000000 --- a/tests/test_carry.gd +++ /dev/null @@ -1,224 +0,0 @@ -@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]) diff --git a/tests/test_carry.gd.uid b/tests/test_carry.gd.uid deleted file mode 100644 index 04b7aa3..0000000 --- a/tests/test_carry.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bcvmvwwjphdf5 diff --git a/tests/test_face.gd b/tests/test_face.gd deleted file mode 100644 index ac4252d..0000000 --- a/tests/test_face.gd +++ /dev/null @@ -1,251 +0,0 @@ -@tool -extends McpTestSuite -## 回归测试:脸部那套「贴花 + 预设 + 画笔」的契约。 -## -## body_mesh 只有一个材质 mat_body,脸是 shader 里贴在身体正面上的一块**贴花**: -## 用模型本地坐标 (x, y) 定位、用法线朝前的程度当遮罩,所以和 UV 孤岛怎么排无关, -## 也不会因为越界糊到肚子/背上。这里盯住:贴花参数还在、矩形大致落在模型包围盒里 -## (模型换尺度或换原点时会红)、以及 FaceRig 不许再抢着传这些 uniform。 - -const PLAYER_SCENE_PATH := "res://character/player/player.tscn" -const SHADER_PATH := "res://character/player/face/face_paint.gdshader" -const PRESET_DIR := "res://character/player/face/presets" -const BODY_MESH_PATH := "Body/mouse/MouseRig/Skeleton3D/body_mesh" -const BODY_MATERIAL_NAME := "mat_body" -## 模型本地坐标,给点容差 -const EPS := 0.05 - - -func suite_name() -> String: - return "face" - - -func _shader_code() -> String: - var file := FileAccess.open(SHADER_PATH, FileAccess.READ) - assert_true(file != null, "%s 加载失败" % SHADER_PATH) - if file == null: - return "" - return file.get_as_text() - - -## 从 shader 源码里抓 `名字 = vec2(a, b);` 的两个数(uniform 和 const 都能抓)。 -func _shader_vec2(code: String, name: String) -> Vector2: - var marker := "%s = vec2(" % name - var start := code.find(marker) - assert_true(start >= 0, "shader 里找不到 %s 的 vec2 默认值" % name) - if start < 0: - return Vector2(INF, INF) - start += marker.length() - var stop := code.find(")", start) - if stop < 0: - return Vector2(INF, INF) - var parts := code.substr(start, stop - start).split(",") - if parts.size() != 2: - return Vector2(INF, INF) - return Vector2(float(parts[0].strip_edges()), float(parts[1].strip_edges())) - - -func _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 - - -func _body_mesh() -> MeshInstance3D: - var player := _player() - if player == null: - return null - return player.get_node_or_null(BODY_MESH_PATH) as MeshInstance3D - - -func test_player_has_face_rig_wired() -> void: - var player := _player() - if player == null: - return - var rig := player.get_node_or_null("FaceRig") as FaceRig - assert_true(rig != null, "Player 下缺少 FaceRig 节点") - if rig == null: - return - assert_eq(rig.surface_index, 0, "body_mesh 只剩一个材质,FaceRig 必须覆盖 surface 0") - assert_true(rig.get_node_or_null(rig.mesh_path) is MeshInstance3D, - "FaceRig.mesh_path(%s)解析不到 MeshInstance3D —— 换模型后路径要跟着改" % rig.mesh_path) - # 贴花参数只写在 shader 里;脚本一旦也传这几个 uniform,就会把调好的值盖掉 - var script_text := FileAccess.open("res://character/player/face/face_rig.gd", FileAccess.READ).get_as_text() - for name in ["face_center", "face_size", "face_flip_y", "face_front_edge"]: - assert_true(not script_text.contains("set_shader_parameter(\"%s\"" % name), - "face_rig.gd 又在传 %s 了 —— shader 顶部调好的值会被脚本覆盖" % name) - - -func test_body_mesh_has_single_material() -> void: - var mi := _body_mesh() - assert_true(mi != null, "找不到 %s" % BODY_MESH_PATH) - if mi == null: - return - var mesh := mi.mesh as ArrayMesh - assert_true(mesh != null, "body_mesh 的 mesh 不是 ArrayMesh") - if mesh == null: - return - assert_eq(mesh.get_surface_count(), 1, "body_mesh 应该只有一个材质槽了") - var material := mesh.surface_get_material(0) - assert_true(material != null, "surface 0 没有材质") - if material == null: - return - assert_eq(material.resource_name, BODY_MATERIAL_NAME, - "body_mesh 的材质不再是 %s,FaceRig 抄身体参数会失效" % BODY_MATERIAL_NAME) - var standard := material as StandardMaterial3D - assert_true(standard != null, "%s 不是 StandardMaterial3D" % BODY_MATERIAL_NAME) - if standard != null: - assert_true(standard.albedo_texture != null, "%s 没有 BaseColor 贴图,身体底色会变成白" % BODY_MATERIAL_NAME) - - -## 贴花矩形是模型本地坐标,必须落在模型自己的包围盒里,否则表情会飘到体外。 -func test_face_decal_inside_body_bounds() -> void: - var code := _shader_code() - if code.is_empty(): - return - var center := _shader_vec2(code, "face_center") - var size := _shader_vec2(code, "face_size") - if not center.is_finite() or not size.is_finite(): - return - assert_true(size.x > 0.0 and size.y > 0.0, "face_size 必须是正数(要翻转请用 face_flip_y)") - var mi := _body_mesh() - if mi == null: - return - var bounds := mi.mesh.get_aabb() - var rect_min := center - size * 0.5 - var rect_max := center + size * 0.5 - assert_true(rect_min.x >= bounds.position.x - EPS and rect_min.y >= bounds.position.y - EPS, - "贴花左下角 %s 跑到模型包围盒 %s 外面了" % [rect_min, bounds]) - assert_true(rect_max.x <= bounds.end.x + EPS and rect_max.y <= bounds.end.y + EPS, - "贴花右上角 %s 跑到模型包围盒 %s 外面了 —— 模型换尺度/原点后会这样" % [rect_max, bounds]) - - -func test_face_shader_has_expected_uniforms() -> void: - var code := _shader_code() - if code.is_empty(): - return - for required in [ - "body_tex", "body_tint", "surface_roughness", "surface_metallic", - "face_center", "face_size", "face_front_edge", "face_flip_y", - "base_tex", "base_uv_min", "base_uv_size", "paint_tex", - ]: - assert_true(code.contains("uniform ") and code.contains(required), "shader 少了 uniform %s" % required) - assert_true(code.contains("texture(body_tex, UV)"), - "shader 必须用 body_tex 采样全身底色,否则脸和身体不是同一块皮肤") - assert_true(code.contains("varying vec3 object_position") and code.contains("object_position.xy"), - "贴花必须用模型本地坐标定位,否则又会被 UV 孤岛边界卡住") - assert_true(code.contains("normalize(object_normal).z"), - "贴花必须按「法线朝前」筛一遍,否则背面的点也会画上表情") - # 模型正面是本地 +Z(尾巴在 -Z 那一侧),写成 -normalize 表情就跑到背上去了 - assert_true(not code.contains("-normalize(object_normal).z"), - "正面遮罩写反了:模型正面是本地 +Z,-normalize 会让表情出现在背上") - assert_true(code.contains("ROUGHNESS = surface_roughness"), "shader 必须复用 mat_body 的 roughness") - assert_true(code.contains("METALLIC = surface_metallic"), "shader 必须复用 mat_body 的 metallic") - # alpha 只代表墨线覆盖率,不能写进空间 ALPHA,否则身体面片会被抠出洞 - assert_true(not code.contains("ALPHA ="), "面部 shader 写了 ALPHA,会把面片渲染成镂空") - - -func test_face_shader_copies_body_pbr_values() -> void: - var mesh_node := _body_mesh() - if mesh_node == null: - return - var body_material := mesh_node.mesh.surface_get_material(0) as StandardMaterial3D - assert_true(body_material != null, "surface 0 应有 StandardMaterial3D") - if body_material == null: - return - assert_true(absf(body_material.roughness - 0.65) < 0.001, - "mat_body roughness 改变:当前 %f;FaceRig 会动态读取它,测试值跟着改" % body_material.roughness) - assert_eq(body_material.metallic, 0.0, "mat_body metallic 改变;FaceRig 会动态读取它") - - -## 内置表情用的是项目里的透明图集副本(原始表情图已随模型一起删掉)。 -func test_builtin_atlas_is_transparent_ink() -> void: - var texture: Texture2D = load(FaceRig.ATLAS_PATH) - assert_true(texture != null, "%s 加载失败" % FaceRig.ATLAS_PATH) - if texture == null: - return - var image := texture.get_image() - var transparent := false - var visible_ink := false - for y in range(0, image.get_height(), 8): - for x in range(0, image.get_width(), 8): - var a := image.get_pixel(x, y).a - transparent = transparent or a < 0.05 - visible_ink = visible_ink or a > 0.5 - assert_true(transparent, "内置表情图集没有透明背景,可能忘了去蓝底") - assert_true(visible_ink, "内置表情图集里没有可见笔画") - - -func test_builtin_face_uv_tiles_exist() -> void: - assert_eq(FaceRig.BUILTIN_EXPRESSIONS.size(), 2, "内置表情清单应包含两张 atlas 表情") - if FaceRig.BUILTIN_EXPRESSIONS.size() < 2: - return - assert_eq(FaceRig.BUILTIN_EXPRESSIONS[0]["id"], "builtin:face_01", "第一个内置表情 ID 改变") - assert_eq(FaceRig.BUILTIN_EXPRESSIONS[1]["id"], "builtin:face_02", "第二个内置表情 ID 改变") - for entry in FaceRig.BUILTIN_EXPRESSIONS: - var rect: Rect2 = entry["rect"] - assert_true(rect.position.x >= 0.0 and rect.position.y >= 0.0, "表情图集 rect 起点越界") - assert_true(rect.end.x <= 1.0 and rect.end.y <= 1.0, "表情图集 rect 终点越界") - - -## 联机同步靠这几个方法名,改名了要一起改 —— 改了就不同步,而且不报错 -func test_face_sync_methods_exist() -> void: - var script := load("res://character/player/face/face_rig.gd") as GDScript - assert_true(script != null, "face_rig.gd 加载失败") - if script == null: - return - var names: Array[String] = [] - for m in script.get_script_method_list(): - names.append(str(m.get("name"))) - for required in [ - "_pull_remote_face", "_rpc_request_face", "_rpc_send_face", - "get_expression_catalog", "select_expression", "save_drawing_as_expression", - ]: - assert_true(names.has(required), "face_rig.gd 少了 %s,表情列表/保存/联机可能失效" % required) - - -## 「没有底图」用运行时创建的 RGBA ImageTexture,而不是磁盘上的全透明 PNG。 -## Godot 导入器会把整张同 alpha 的 PNG 优化成 RGB,丢掉 alpha;运行时的 Image 则保留。 -func test_runtime_blank_texture_preserves_alpha() -> void: - var image := Image.create(2, 2, false, Image.FORMAT_RGBA8) - var texture := ImageTexture.create_from_image(image) - assert_true(texture != null, "运行时空白 ImageTexture 创建失败") - if texture == null: - return - var loaded := texture.get_image() - assert_eq(loaded.get_format(), Image.FORMAT_RGBA8, "空白贴图被降成了 RGB 格式") - assert_eq(loaded.get_pixel(0, 0).a, 0.0, "空白贴图 alpha 应为 0") - assert_eq(loaded.get_pixel(1, 1).a, 0.0, "空白贴图中心 alpha 应为 0") - - -func test_presets_are_square() -> void: - var dir := DirAccess.open(PRESET_DIR) - assert_true(dir != null, "打不开预设目录 %s" % PRESET_DIR) - if dir == null: - return - var found := 0 - for f in dir.get_files(): - if not f.ends_with(".png"): - continue - found += 1 - var tex: Texture2D = load(PRESET_DIR + "/" + f) - assert_true(tex != null, "预设 %s 加载失败" % f) - if tex == null: - continue - var s := tex.get_size() - assert_eq(int(s.x), int(s.y), "预设 %s 不是正方形(%s),贴到脸上会拉伸" % [f, s]) - var image := tex.get_image() - var transparent := false - var visible_ink := false - for y in range(0, image.get_height(), 4): - for x in range(0, image.get_width(), 4): - var a := image.get_pixel(x, y).a - transparent = transparent or a < 0.05 - visible_ink = visible_ink or a > 0.5 - assert_true(transparent, "预设 %s 没有透明背景,可能忘了去蓝底" % f) - assert_true(visible_ink, "预设 %s 没有可见笔画" % f) - assert_gt(found, 0, "%s 里一张预设都没有" % PRESET_DIR) diff --git a/tests/test_face.gd.uid b/tests/test_face.gd.uid deleted file mode 100644 index 49d4ce4..0000000 --- a/tests/test_face.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://brw0ntpgcol2o diff --git a/tests/test_net_replication.gd b/tests/test_net_replication.gd deleted file mode 100644 index 47b0e82..0000000 --- a/tests/test_net_replication.gd +++ /dev/null @@ -1,237 +0,0 @@ -@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 diff --git a/tests/test_net_replication.gd.uid b/tests/test_net_replication.gd.uid deleted file mode 100644 index fe6d170..0000000 --- a/tests/test_net_replication.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://nvx3ojicfxh5 diff --git a/tests/test_zipline.gd b/tests/test_zipline.gd deleted file mode 100644 index 73c4f20..0000000 --- a/tests/test_zipline.gd +++ /dev/null @@ -1,224 +0,0 @@ -@tool -extends McpTestSuite -## 回归测试:滑索的几何契约,以及它和玩家控制器的接线。 -## -## 滑索坏了不会报错,只会「人挂在半空 / 往高处滑 / 挂不上」—— 全是静默失效。 -## 所以这里钉死的是几何约定: -## 1. 曲线两端必须正好落在两个锚点上,否则「看到的缆」和「能挂的点」是两回事; -## 2. sag 是「中点下垂深度」,不是别的什么比例; -## 3. tangent() 从 A 指向 B,反了人会往高处滑; -## 4. closest_point().dir 永远指向低的那一端,滑索只能往下滑; -## 5. ride_point() 在缆下方 hang_offset 处,否则人卡在缆里。 -## -## Zipline 是 @tool 脚本,缆绳网格在 _ready() 里现造 —— 所以实例必须真的进树, -## 不能像纯数据那样只 instantiate() 就断言。 - -const ZIPLINE_SCENE_PATH := "res://world/zipline/zipline.tscn" -const ZIPLINE_SCRIPT_PATH := "res://world/zipline/zipline.gd" -const PLAYER_SCENE_PATH := "res://character/player/player.tscn" -const INTERACT_ACTION := "interact" -## 挂在编辑中场景下。前缀 _McpTest 是测试运行器兜底清理认的标记 -const TEST_NODE_NAME := "_McpTestZipline" -## 测试自己造的父节点:不带任何变换,免得编辑中场景的缩放漏进来 -const TEST_PARENT_NAME := "_McpTestZiplineHolder" - -var _zip: Zipline = null - - -func suite_name() -> String: - return "zipline" - - -func suite_setup(_ctx: Dictionary) -> void: - if EditorInterface.get_edited_scene_root() == null: - skip_suite("需要先打开一个场景:Zipline 是 @tool,进树才会生成缆绳网格") - return - # 先试装一次再丢掉 —— 否则 13 个测试会各自撞同一堵墙,报 13 条一样的错 - var probe := _instantiate_zipline() - if probe == null: - fail_setup("%s 加载/实例化失败,或根节点没挂 Zipline 脚本" % ZIPLINE_SCENE_PATH) - return - probe.free() - - -## 每个测试拿一个干净实例:有些测试会故意篡改锚点,共享实例会让结果依赖执行顺序。 -## 挂在**编辑器根节点**下的测试父节点里,而不是编辑中场景根下 —— -## 滑索的几何是在它自己的空间里定义的,挂在场景根下会被那个场景的变换带偏 -## (player.tscn 的根节点就带 0.2 缩放,实测把中点下垂从 1.2 变成 0.24)。 -func setup() -> void: - _zip = _instantiate_zipline() - if _zip != null: - Engine.get_main_loop().root.add_child(_make_holder(Vector3.ONE, _zip)) - - -func teardown() -> void: - if is_instance_valid(_zip) and _zip.get_parent() != null: - _zip.get_parent().remove_child(_zip) - _zip.free() - _zip = null - - -# ---------------- 场景接线 ---------------- - -func test_scene_root_script() -> void: - var script: Script = _zip.get_script() - assert_true(script != null, "滑索根节点没有脚本") - if script != null: - assert_eq(script.resource_path, ZIPLINE_SCRIPT_PATH, "滑索根节点挂的不是 zipline.gd") - - -func test_anchors_exist() -> void: - assert_true(_zip.anchor_a != null, "缺少 AnchorA(Marker3D),缆绳没有起点") - assert_true(_zip.anchor_b != null, "缺少 AnchorB(Marker3D),缆绳没有终点") - - -## 缆绳网格是 _ready() 里现造的,不是场景里的节点。它没生成的话游戏里什么都看不见, -## 但不会报任何错 —— 所以长度和网格都要断言。 -func test_cable_mesh_generated() -> void: - assert_gt(_zip.length, 0.0, "缆绳长度为 0:锚点重合,或者 _rebuild_cable() 没跑到") - var cable := _zip.get_node_or_null("Cable") as MeshInstance3D - assert_true(cable != null, "找不到 Cable 子节点(应该在 _ready() 里生成)") - if cable != null: - assert_true(cable.mesh != null, "Cable 没有网格:SurfaceTool 没 commit 成功") - - -## 玩家控制器用 $InteractHint 拿提示节点,被改名或删掉会在运行时炸。 -func test_player_scene_has_interact_hint() -> void: - var packed := ResourceLoader.load(PLAYER_SCENE_PATH, "", ResourceLoader.CACHE_MODE_REPLACE) as PackedScene - assert_true(packed != null, "%s 加载失败" % PLAYER_SCENE_PATH) - if packed == null: - return - var player := packed.instantiate() - track(player) - var hint := player.get_node_or_null("InteractHint") as Label3D - assert_true(hint != null, "Player 下缺少 InteractHint(Label3D),$InteractHint 会取到 null") - if hint != null: - assert_false(hint.visible, "InteractHint 默认应该是隐藏的") - - -## 挂索/脱手都走 InputMap 的 interact 动作;动作没了按 E 没反应,而且不报错。 -## 编辑器进程的 InputMap 只带内建动作,项目动作要等游戏进程启动时才 load 进去 —— -## 不先补这一步,这条断言在编辑器测试环境里必然假红。 -func test_interact_action_exists() -> void: - InputMap.load_from_project_settings() - assert_true(InputMap.has_action(INTERACT_ACTION), "InputMap 里没有 %s 动作" % INTERACT_ACTION) - if not InputMap.has_action(INTERACT_ACTION): - return - var bound_to_e := false - for event in InputMap.action_get_events(INTERACT_ACTION): - if event is InputEventKey and (event as InputEventKey).keycode == KEY_E: - bound_to_e = true - assert_true(bound_to_e, "%s 没有绑定 E 键" % INTERACT_ACTION) - - -# ---------------- 几何契约 ---------------- - -func test_ends_match_anchors() -> void: - assert_true(_zip.sample(0.0).is_equal_approx(_zip.anchor_a.global_position), "sample(0) 不在 AnchorA 上") - assert_true(_zip.sample(1.0).is_equal_approx(_zip.anchor_b.global_position), "sample(1) 不在 AnchorB 上") - - -## sag 的定义是「中点相对两点连线中点的下垂深度」。这条一错,缆绳的弧度和手感全变。 -func test_sag_is_midpoint_dip() -> void: - var straight_mid := (_zip.anchor_a.global_position + _zip.anchor_b.global_position) * 0.5 - var dip := straight_mid.y - _zip.sample(0.5).y - assert_true(is_equal_approx(dip, _zip.sag), "中点下垂 %.4f 与 sag %.4f 不符" % [dip, _zip.sag]) - - -## 方向反了人会往高处滑 —— 最容易写错、又最难看出来的一处。 -func test_tangent_points_from_a_to_b() -> void: - var along := (_zip.anchor_b.global_position - _zip.anchor_a.global_position).normalized() - for t in [0.0, 0.25, 0.5, 0.75, 1.0]: - assert_gt(_zip.tangent(t).dot(along), 0.9, "tangent(%s) 没从 A 指向 B" % t) - - -## 父节点带缩放时,整条缆要跟着一起缩 —— 形状(下垂比例)不能走样。 -## 这里踩过:曲线用 global 锚点 lerp、却加未缩放的 sag,父节点一缩放形状就偏, -## 挂在带缩放的父节点下 tangent 会歪到 27°,直接把人往错方向送。 -func test_shape_survives_parent_scale() -> void: - var packed := load(ZIPLINE_SCENE_PATH) as PackedScene - if packed == null: - return - var zip := packed.instantiate() as Zipline - if zip == null: - return - zip.name = TEST_NODE_NAME - Engine.get_main_loop().root.add_child(_make_holder(Vector3(0.2, 0.2, 0.2), zip)) - var along := (zip.anchor_b.global_position - zip.anchor_a.global_position).normalized() - for t in [0.0, 0.5, 1.0]: - assert_gt(zip.tangent(t).dot(along), 0.9, "缩放父节点下 tangent(%s) 形状走样了" % t) - # 中点的世界下垂量应该是 sag × 父节点缩放 - var dip := zip.anchor_a.global_position.lerp(zip.anchor_b.global_position, 0.5).distance_to(zip.sample(0.5)) - assert_true(absf(dip - zip.sag * 0.2) < 0.01, - "缩放后中点下垂 %.4f,期望 sag×0.2 = %.4f" % [dip, zip.sag * 0.2]) - - -## 玩家挂索靠这个接口。返回的点必须在曲线上,distance 必须是真实欧氏距离。 -func test_closest_point_lands_on_curve() -> void: - var probe := _zip.sample(0.3) + Vector3(1.0, 1.0, 0.0) - var info := _zip.closest_point(probe) - assert_has_key(info, "t") - assert_has_key(info, "dir") - assert_has_key(info, "remaining") - assert_has_key(info, "distance") - assert_true(info.point.is_equal_approx(_zip.sample(info.t)), "返回的 point 不在曲线上") - assert_true(is_equal_approx(info.distance, probe.distance_to(info.point)), "distance 不是到 point 的欧氏距离") - - -## 滑索只能往下滑:出口必须是低的那一端。 -func test_dir_points_to_lower_end() -> void: - assert_true( - _zip.anchor_b.global_position.y < _zip.anchor_a.global_position.y, - "这个测试假定场景里的 AnchorB 更低" - ) - assert_eq(_zip.closest_point(_zip.sample(0.5)).dir, 1, "B 更低时应该往 B 滑") - - -## 把 B 抬到比 A 高,出口就该换成 A —— 滑索不会把人往上拽。 -func test_dir_flips_when_b_is_higher() -> void: - var lifted := _zip.anchor_b.global_position - lifted.y = _zip.anchor_a.global_position.y + 5.0 - _zip.anchor_b.global_position = lifted - assert_eq(_zip.closest_point(_zip.sample(0.5)).dir, -1, "B 比 A 高时应该往 A 滑") - - -## 人挂在缆下方,不是在缆上 —— 否则模型会卡在缆里。 -func test_ride_point_hangs_below_cable() -> void: - for t in [0.0, 0.5, 1.0]: - var offset := _zip.sample(t) - _zip.ride_point(t) - assert_true( - offset.is_equal_approx(Vector3.UP * _zip.hang_offset), - "ride_point(%s) 不在缆下方 hang_offset 处" % t - ) - - -## 越靠近出口,剩余行程越短。玩家用它判断「挂上还有没有得滑」。 -func test_remaining_shrinks_toward_exit() -> void: - var near_start := _zip.closest_point(_zip.sample(0.1)) - var near_exit := _zip.closest_point(_zip.sample(0.95)) - assert_gt(near_start.remaining, near_exit.remaining, "剩余行程没有随 t 递减") - assert_true( - near_exit.remaining < _zip.min_ride, - "出口附近的剩余行程应小于 min_ride,否则玩家会挂在终点上反复弹开" - ) - - -func _instantiate_zipline() -> Zipline: - var packed := ResourceLoader.load(ZIPLINE_SCENE_PATH, "", ResourceLoader.CACHE_MODE_REPLACE) as PackedScene - if packed == null: - return null - var zip := packed.instantiate() as Zipline - if zip == null: - return null - zip.name = TEST_NODE_NAME - return zip - - -## 造一个父节点(可带缩放)并把 node 挂进去,返回那个父节点。测试用它把滑索和编辑中场景隔开。 -func _make_holder(scale_factor: Vector3, node: Node) -> Node3D: - var holder := Node3D.new() - holder.name = TEST_PARENT_NAME - holder.scale = scale_factor - holder.add_child(node) - track(holder) - return holder diff --git a/tests/test_zipline.gd.uid b/tests/test_zipline.gd.uid deleted file mode 100644 index 962dfd9..0000000 --- a/tests/test_zipline.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://b36lbd1qvlebs