Files
agame/tests/test_zipline.gd
T
2026-10-02 09:32:11 +08:00

189 lines
8.0 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@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 没反应,而且不报错。
## 编辑器进程的 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)
## 玩家挂索靠这个接口。返回的点必须在曲线上,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