@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