移除测试

This commit is contained in:
2026-10-08 16:40:32 +08:00
parent a5c08088b4
commit 9b1d30339f
8 changed files with 0 additions and 940 deletions
-224
View File
@@ -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])
-1
View File
@@ -1 +0,0 @@
uid://bcvmvwwjphdf5
-251
View File
@@ -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)
-1
View File
@@ -1 +0,0 @@
uid://brw0ntpgcol2o
-237
View File
@@ -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
-1
View File
@@ -1 +0,0 @@
uid://nvx3ojicfxh5
-224
View File
@@ -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
-1
View File
@@ -1 +0,0 @@
uid://b36lbd1qvlebs