This commit is contained in:
2026-09-24 16:35:28 +08:00
parent 2f662c6c6f
commit a1308cf8fd
11 changed files with 614 additions and 3 deletions
+185
View File
@@ -0,0 +1,185 @@
@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"
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()
## 每个测试拿一个干净实例:有些测试会故意篡改锚点,共享实例会让结果依赖执行顺序。
func setup() -> void:
_zip = _instantiate_zipline()
if _zip != null:
EditorInterface.get_edited_scene_root().add_child(_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 没反应,而且不报错。
func test_interact_action_exists() -> void:
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)
## 玩家挂索靠这个接口。返回的点必须在曲线上,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
+1
View File
@@ -0,0 +1 @@
uid://b36lbd1qvlebs