滑索
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://b36lbd1qvlebs
|
||||
Reference in New Issue
Block a user