滑索
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
# 2026-09-24
|
||||
|
||||
## 滑索(Zipline)组件 —— 新增
|
||||
|
||||
需求:Godot 4 第三人称滑索,要求全程用 Godot MCP 搭建。
|
||||
|
||||
新增 / 改动:
|
||||
|
||||
- `world/zipline/zipline.gd` + `zipline.tscn`:两个 Marker3D 锚点 + 抛物线下垂曲线。
|
||||
缆绳网格在 `_ready()` 用 SurfaceTool 现造,子节点不给 owner → 不会被写进 .tscn。
|
||||
对外接口:`sample / tangent / ride_point / closest_point`;手感参数(速度、刹车、吸附半径)都放在这一侧。
|
||||
- `character/player/player.gd`:新增 `_find_zipline / _attach_zipline / _detach_zipline / _ride_zipline`。
|
||||
缆上直接写 `global_position`,不走 `move_and_slide()`;相机在缆上缓慢对齐到行进方向。
|
||||
- `character/player/player.tscn`:新增 `InteractHint`(Label3D),沿用 HealthLabel 的「节点配置」做法。
|
||||
- `project.godot`:新增 `interact` 输入动作(E)。
|
||||
- `world/main.tscn`:演示关卡 —— LaunchRamp(26.57° 斜引道)+ LaunchDeck(顶面 y=6.0)+ Zipline 实例。
|
||||
- `tests/test_zipline.gd`:13 项回归测试,钉住几何契约(端点、下垂量、切线方向、下滑方向、悬挂偏移)。
|
||||
|
||||
验证:全量测试 20/20 通过(180 条断言);实跑三次,按 E 后玩家从 `(0,0,0)` 滑到 `(0,0,18.8)` 缆末端。
|
||||
|
||||
关键取舍:
|
||||
|
||||
- `sag` 定义成「中点下垂深度」而不是比例 —— 调参所见即所得,不用解悬链线方程。
|
||||
- 出口固定为**较低**的那个锚点(滑索只能往下滑),两端等高时退回 A→B。
|
||||
- 模型里没有滑索动画片段,所以缆上复用下落姿态:`animator.set_motion(velocity, false, false)`,
|
||||
没有为此在 `PlayerAnimator` 里加新状态。
|
||||
@@ -0,0 +1,24 @@
|
||||
# 项目长期记忆
|
||||
|
||||
## 工程约定
|
||||
|
||||
- 玩家控制器只认「运动结果」,不认识任何动画片段名;表现层在 `Body/PlayerAnimator`。
|
||||
新增动作状态改 `player_animator.gd` 的 `ANIM_CLIPS`,不要往 `player.gd` 里塞片段名。
|
||||
- 组件的可调参数放在组件自己身上(如 Zipline 的速度 / 刹车 / 吸附半径),控制器只读不定义。
|
||||
- 头顶 UI(血条、交互提示)是 `player.tscn` 里的 Label3D 节点,不在代码里动态创建。
|
||||
- 测试用 `McpTestSuite`(`addons/godot_ai/testing`),脚本放 `res://tests/`。
|
||||
|
||||
## Godot MCP 踩坑
|
||||
|
||||
- **数值标量会被字符串化**:直接调 `node_set_property` 传 `1` / `false` 会报
|
||||
`WRONG_TYPE: Cannot write String to a int/bool property`。
|
||||
绕法:改用 `batch_execute`,把 value 放进每条 command 的 `params` 里(嵌套数字能正常保型)。
|
||||
字符串、Vector / Color 对象不受影响。
|
||||
- **@tool 脚本现造的节点不会写进 .tscn**:`add_child` 后不给 `owner`,
|
||||
`PackedScene::pack()` 就会跳过它。缆绳网格因此可以纯由锚点推导,不存在「文件和锚点对不上」的脏数据。
|
||||
- **新 `class_name` 要跑一次 `filesystem_manage(op="scan")`** 才进全局类表,
|
||||
否则别的脚本把它当类型引用会解析不到。
|
||||
- `game_manage(op="suspend")` 期间**输入事件不送达**,`next_frame` 也拦不住物理帧;
|
||||
想抓「运行中途某一瞬间」的画面不可靠,只能靠日志 / 属性断言验证。
|
||||
- 测试跑在编辑器进程里,`@tool` 节点必须真的 `add_child` 进编辑中场景才会跑 `_ready()`;
|
||||
用完在 `teardown()` 摘掉,节点名以 `_McpTest` 开头可被测试运行器兜底清理。
|
||||
+129
-3
@@ -14,9 +14,13 @@ extends CharacterBody3D
|
||||
## │ └─ SpringArm (SpringArm3D) ← 承担俯仰 pitch,自动避障
|
||||
## │ └─ Camera (Camera3D)
|
||||
## ├─ Sync (MultiplayerSynchronizer) ← 同步 position、Body.rotation、health、动画状态
|
||||
## └─ HealthLabel (Label3D) ← 头顶血条(节点配置,不是代码建的)
|
||||
## ├─ HealthLabel (Label3D) ← 头顶血条(节点配置,不是代码建的)
|
||||
## └─ InteractHint (Label3D) ← 靠近滑索时的「按 E」提示,同样在场景里配好
|
||||
##
|
||||
## 操作:WASD/方向键移动,鼠标转视角,Shift 疾跑,空格跳跃,Esc 释放/锁定鼠标
|
||||
## 滑索:走到缆下按 E 挂上,沿缆滑向低端,缆上按 S 刹车、空格跳离。
|
||||
## 手感参数(速度/刹车/吸附半径)全在 Zipline 组件上,本文件只读不定义 —— 见文件末尾。
|
||||
##
|
||||
## 操作:WASD/方向键移动,鼠标转视角,Shift 疾跑,空格跳跃,E 滑索,Esc 释放/锁定鼠标
|
||||
|
||||
## 跳跃输入缓冲帧数(输入事件在渲染帧到达,物理帧读取,避免漏跳)
|
||||
const JUMP_BUFFER_FRAMES: int = 6
|
||||
@@ -55,6 +59,8 @@ const JUMP_BUFFER_FRAMES: int = 6
|
||||
@onready var spring_arm: SpringArm3D = $CameraRig/SpringArm
|
||||
## 头顶血条是 player.tscn 里的 Label3D 节点(3D 世界里的文字,会跟着角色走)
|
||||
@onready var _health_label: Label3D = $HealthLabel
|
||||
## 滑索操作提示,同样在 player.tscn 里配好,默认隐藏
|
||||
@onready var _hint: Label3D = $InteractHint
|
||||
|
||||
## 动画状态机(挂在 Body 下)。只把「运动结果」交给它,也只知道它会自己播动画。
|
||||
@onready var animator: PlayerAnimator = $Body/PlayerAnimator
|
||||
@@ -66,6 +72,18 @@ var _jump_buffer: int = 0
|
||||
## 本节点是否由本机玩家操控。由世界节点在生成时设置 authority 决定,见 world/main.gd。
|
||||
var _is_local: bool = true
|
||||
|
||||
## 当前挂着的滑索。非 null 时 _physics_process 走缆上分支,常规移动/重力/贴地全部让位。
|
||||
## 只在本机权威端写;远端玩家的位置由 Sync 复制过来,跟着缆一起动。
|
||||
var _zipline: Zipline = null
|
||||
## 沿缆的进度参数 t∈[0,1]。缆的几何由 Zipline 负责,这里只记「我走到哪了」。
|
||||
var _zip_t: float = 0.0
|
||||
## 滑动方向:+1 往 AnchorB、-1 往 AnchorA。永远是往低的那一端
|
||||
var _zip_dir: int = 1
|
||||
## 沿缆速度(米/秒)
|
||||
var _zip_speed: float = 0.0
|
||||
## 挂上瞬间「人相对缆」的偏移,随后衰减到 0 —— 吸附是滑过去的,不是瞬移
|
||||
var _zip_grab_offset: Vector3 = Vector3.ZERO
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_report_identity()
|
||||
@@ -100,7 +118,16 @@ func _unhandled_input(event: InputEvent) -> void:
|
||||
camera_rig.rotation.y = _yaw
|
||||
spring_arm.rotation.x = _pitch
|
||||
elif event.is_action_pressed("jump"):
|
||||
_jump_buffer = JUMP_BUFFER_FRAMES
|
||||
# 缆上按跳 = 主动脱手,额外给一个向上的小跳,方便够到旁边的平台
|
||||
if _zipline != null:
|
||||
_detach_zipline(true)
|
||||
else:
|
||||
_jump_buffer = JUMP_BUFFER_FRAMES
|
||||
elif event.is_action_pressed("interact"):
|
||||
if _zipline != null:
|
||||
_detach_zipline()
|
||||
else:
|
||||
_attach_zipline()
|
||||
elif event.is_action_pressed("ui_cancel"):
|
||||
set_mouse_captured(Input.mouse_mode != Input.MOUSE_MODE_CAPTURED)
|
||||
elif event is InputEventMouseButton and event.pressed and Input.mouse_mode != Input.MOUSE_MODE_CAPTURED:
|
||||
@@ -118,6 +145,18 @@ func set_mouse_captured(captured: bool) -> void:
|
||||
func _physics_process(delta: float) -> void:
|
||||
if not _is_local:
|
||||
return
|
||||
|
||||
# 靠近缆时给个操作提示。判定与 _attach_zipline 共用 _find_zipline(),
|
||||
# 免得出现「提示亮了但按 E 挂不上」这种对不上的情况。
|
||||
_hint.visible = _zipline != null or _find_zipline() != null
|
||||
if _hint.visible:
|
||||
_hint.text = "空格 跳离 · E 松手" if _zipline != null else "按 E 挂上滑索"
|
||||
|
||||
# 挂在缆上时,下面的常规重力/输入/贴地全部让位给缆上运动
|
||||
if _zipline != null:
|
||||
_ride_zipline(delta)
|
||||
return
|
||||
|
||||
# --- 重力 ---
|
||||
if is_on_floor():
|
||||
if velocity.y < 0.0:
|
||||
@@ -161,6 +200,93 @@ func _physics_process(delta: float) -> void:
|
||||
animator.set_motion(velocity, is_on_floor(), Input.is_action_pressed("sprint"))
|
||||
|
||||
|
||||
# ---------------- 滑索 ----------------
|
||||
# 拆成三件事:找缆(_find_zipline)、挂上(_attach_zipline)、缆上运动(_ride_zipline)。
|
||||
# 缆的几何与手感参数都在 Zipline 组件里,这里只管「人」的状态,参数只读不定义。
|
||||
|
||||
## 找脚下能挂的缆,找不到返回 null。挂索与「按 E」提示共用同一套判定。
|
||||
func _find_zipline() -> Zipline:
|
||||
var best: Zipline = null
|
||||
var best_distance := INF
|
||||
for node in get_tree().get_nodes_in_group(Zipline.GROUP):
|
||||
var zip := node as Zipline
|
||||
if zip == null or zip.length <= 0.0:
|
||||
continue
|
||||
var info := zip.closest_point(global_position)
|
||||
# 两种情况都不挂:够不着,或者挂上就没几米可滑(后者会让人刚挂上就被弹下来)
|
||||
if info.distance > zip.attach_radius or info.remaining < zip.min_ride:
|
||||
continue
|
||||
if info.distance < best_distance:
|
||||
best_distance = info.distance
|
||||
best = zip
|
||||
return best
|
||||
|
||||
|
||||
## 挂上最近的一条缆,人就近吸附到缆上最近一点。
|
||||
func _attach_zipline() -> void:
|
||||
var zip := _find_zipline()
|
||||
if zip == null:
|
||||
return
|
||||
var info := zip.closest_point(global_position)
|
||||
_zipline = zip
|
||||
_zip_t = info.t
|
||||
_zip_dir = info.dir
|
||||
# 带上入缆时的水平速度,冲上去挂索不会突然一顿
|
||||
_zip_speed = maxf(Vector2(velocity.x, velocity.z).length() * 0.5, 2.0)
|
||||
# 把当前偏移记下来交给 _ride_zipline 逐帧衰减,避免瞬移到缆上
|
||||
_zip_grab_offset = global_position - zip.ride_point(_zip_t)
|
||||
velocity = Vector3.ZERO
|
||||
|
||||
|
||||
## 脱离缆索。hop=true 时额外给一个向上的小跳(主动跳离用)。
|
||||
func _detach_zipline(hop: bool = false) -> void:
|
||||
if _zipline == null:
|
||||
return
|
||||
# 惯性带走:沿缆方向的速度原样保留,脱手后就是一次抛物线
|
||||
velocity = _zipline.tangent(_zip_t) * float(_zip_dir) * _zip_speed
|
||||
if hop:
|
||||
velocity += Vector3.UP * 3.0
|
||||
_zipline = null
|
||||
_zip_speed = 0.0
|
||||
|
||||
|
||||
## 缆上运动:沿曲线加速下滑,S 刹车,到头自动脱手。
|
||||
##
|
||||
## 位置直接写 global_position,不走 move_and_slide():悬空缆段上没有需要解算的碰撞,
|
||||
## 让物理引擎去推反而会和「贴着曲线走」这个硬约束打架。
|
||||
## velocity 仍然维护 —— 一是给动画器读,二是脱手后能带着惯性飞出去。
|
||||
func _ride_zipline(delta: float) -> void:
|
||||
var tan := _zipline.tangent(_zip_t) * float(_zip_dir)
|
||||
# 重力沿缆方向的分量:缆越陡加速越猛,平缆上几乎不加速。
|
||||
# 下坡时 tan.y 是负的,所以前面要加负号。
|
||||
_zip_speed += -_gravity * _zipline.gravity_scale * tan.y * delta
|
||||
if Input.is_action_pressed("move_back"):
|
||||
_zip_speed -= _zipline.brake_accel * delta
|
||||
_zip_speed = clampf(_zip_speed, 0.0, _zipline.max_speed)
|
||||
|
||||
# t 是曲线参数而不是弧长,但 sag 相对缆长很小时两者差别可以忽略
|
||||
var next_t := _zip_t + float(_zip_dir) * _zip_speed / _zipline.length * delta
|
||||
if next_t <= 0.0 or next_t >= 1.0:
|
||||
# 到头了:先落到端点再脱手,免得停在离端点还差一截的地方
|
||||
_zip_t = clampf(next_t, 0.0, 1.0)
|
||||
global_position = _zipline.ride_point(_zip_t)
|
||||
_detach_zipline()
|
||||
return
|
||||
|
||||
_zip_t = next_t
|
||||
_zip_grab_offset = _zip_grab_offset.lerp(Vector3.ZERO, clampf(delta / 0.12, 0.0, 1.0))
|
||||
global_position = _zipline.ride_point(_zip_t) + _zip_grab_offset
|
||||
velocity = tan * _zip_speed
|
||||
var travel_yaw := atan2(-tan.x, -tan.z)
|
||||
body.rotation.y = lerp_angle(body.rotation.y, travel_yaw, clampf(turn_speed * delta, 0.0, 1.0))
|
||||
# 相机也跟着摆到行进方向背后 —— 否则人是横着飞的,看不见前面要去哪。
|
||||
# 速率刻意比转身慢:玩家用鼠标看的视角不会被立刻拽回去。
|
||||
_yaw = lerp_angle(_yaw, travel_yaw, clampf(3.0 * delta, 0.0, 1.0))
|
||||
camera_rig.rotation.y = _yaw
|
||||
# 缆上没有「落地」概念,交给动画器当下落姿态处理(模型里没有专门的滑索片段)
|
||||
animator.set_motion(velocity, false, false)
|
||||
|
||||
|
||||
func _refresh_health_label() -> void:
|
||||
# 血条节点在 player.tscn 里(Hud/HealthLabel)。
|
||||
# 同步可能在 _ready() 之前就把 health 写进来,那时 _health_label 还是 null,直接跳过。
|
||||
|
||||
@@ -2810,3 +2810,13 @@ no_depth_test = true
|
||||
text = "HP 100 / 100"
|
||||
font_size = 64
|
||||
outline_size = 8
|
||||
|
||||
[node name="InteractHint" type="Label3D" parent="." unique_id=1718788495]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.75, 0)
|
||||
visible = false
|
||||
billboard = 1
|
||||
no_depth_test = true
|
||||
modulate = Color(1, 0.95, 0.6, 1)
|
||||
text = "按 E 挂上滑索"
|
||||
font_size = 48
|
||||
outline_size = 8
|
||||
|
||||
@@ -74,6 +74,11 @@ sprint={
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194325,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||
]
|
||||
}
|
||||
interact={
|
||||
"deadzone": 0.5,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":69,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||
]
|
||||
}
|
||||
|
||||
[physics]
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -5,6 +5,7 @@
|
||||
[ext_resource type="Sky" uid="uid://bi632suyifms5" path="res://world/sky/cartoon_sky.tres" id="2_cartoon"]
|
||||
[ext_resource type="PackedScene" path="res://world/ocean/sea.tscn" id="3_sea"]
|
||||
[ext_resource type="Material" path="res://world/ocean/island_mesa.tres" id="4_island"]
|
||||
[ext_resource type="PackedScene" uid="uid://12qonfnowpas" path="res://world/zipline/zipline.tscn" id="6_i1eo7"]
|
||||
|
||||
[sub_resource type="BoxShape3D" id="BoxShape3D_hyim0"]
|
||||
size = Vector3(58, 0.5, 58)
|
||||
@@ -20,6 +21,24 @@ sky = ExtResource("2_cartoon")
|
||||
ambient_light_source = 3
|
||||
reflected_light_source = 2
|
||||
|
||||
[sub_resource type="BoxShape3D" id="BoxShape3D_gu74f"]
|
||||
size = Vector3(4, 0.6, 14)
|
||||
|
||||
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_04q08"]
|
||||
albedo_color = Color(0.45, 0.31, 0.19, 1)
|
||||
|
||||
[sub_resource type="BoxMesh" id="BoxMesh_vrojb"]
|
||||
size = Vector3(4, 0.6, 14)
|
||||
|
||||
[sub_resource type="BoxShape3D" id="BoxShape3D_qnd6n"]
|
||||
size = Vector3(4, 0.4, 5.5)
|
||||
|
||||
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_op3e8"]
|
||||
albedo_color = Color(0.45, 0.31, 0.19, 1)
|
||||
|
||||
[sub_resource type="BoxMesh" id="BoxMesh_vid1e"]
|
||||
size = Vector3(4, 0.4, 5.5)
|
||||
|
||||
[node name="Main" type="Node3D" unique_id=631359418]
|
||||
script = ExtResource("1_hyim0")
|
||||
player_scene = ExtResource("2_4f7fj")
|
||||
@@ -50,3 +69,25 @@ environment = SubResource("Environment_gu74f")
|
||||
|
||||
[node name="MultiplayerSpawner" type="MultiplayerSpawner" parent="." unique_id=1020023513]
|
||||
spawn_path = NodePath("..")
|
||||
|
||||
[node name="LaunchRamp" type="StaticBody3D" parent="." unique_id=999942303]
|
||||
transform = Transform3D(1, 0, 0, 0, 0.89438856, -0.44729084, 0, 0.44729084, 0.89438856, 0, 2.8, -14)
|
||||
|
||||
[node name="Shape" type="CollisionShape3D" parent="LaunchRamp" unique_id=1960823727]
|
||||
shape = SubResource("BoxShape3D_gu74f")
|
||||
|
||||
[node name="Mesh" type="MeshInstance3D" parent="LaunchRamp" unique_id=1190947350]
|
||||
material_override = SubResource("StandardMaterial3D_04q08")
|
||||
mesh = SubResource("BoxMesh_vrojb")
|
||||
|
||||
[node name="LaunchDeck" type="StaticBody3D" parent="." unique_id=1144716368]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 5.8, -22.8)
|
||||
|
||||
[node name="Shape" type="CollisionShape3D" parent="LaunchDeck" unique_id=1332771168]
|
||||
shape = SubResource("BoxShape3D_qnd6n")
|
||||
|
||||
[node name="Mesh" type="MeshInstance3D" parent="LaunchDeck" unique_id=206374851]
|
||||
material_override = SubResource("StandardMaterial3D_op3e8")
|
||||
mesh = SubResource("BoxMesh_vid1e")
|
||||
|
||||
[node name="Zipline" parent="." unique_id=982324784 instance=ExtResource("6_i1eo7")]
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
@tool
|
||||
extends Node3D
|
||||
class_name Zipline
|
||||
## 滑索(Zipline):两点之间一条会下垂的钢缆。玩家走到缆下按 E 挂上,
|
||||
## 沿缆滑向较低的那一端,中途可按 S 刹车、按空格主动跳离。
|
||||
##
|
||||
## 场景结构(res://world/zipline/zipline.tscn):
|
||||
## Zipline (Node3D, 本脚本)
|
||||
## ├─ AnchorA (Marker3D) ← 高处起点,在视口里直接拖
|
||||
## └─ AnchorB (Marker3D) ← 低处终点
|
||||
## 缆绳网格是本脚本在 _ready() 里生成的一个 MeshInstance3D 子节点,
|
||||
## 故意不给 owner,所以不会被写进 .tscn —— 它完全由两个锚点推导出来,
|
||||
## 不存在「文件里的网格和锚点对不上」这种脏数据。
|
||||
##
|
||||
## 手感参数(速度/刹车/吸附半径)放在「缆」这一侧而不是玩家身上:
|
||||
## 同一张关卡里可以有快缆和慢缆,玩家只负责读参数,不负责定义手感。
|
||||
##
|
||||
## 曲线模型:两点连线再减去一个抛物线形下垂量
|
||||
## P(t) = lerp(A, B, t) - UP * sag * 4t(1-t)
|
||||
## 权重 4t(1-t) 在 t=0/1 处为 0、t=0.5 处为 1,所以锚点高度就是真实挂点高度、
|
||||
## sag 就是中点下垂深度 —— 调参所见即所得,不用去解悬链线方程。
|
||||
##
|
||||
## 约束:本节点不要旋转/缩放,靠拖动两个锚点摆位。
|
||||
|
||||
## 所有滑索都进这个组,玩家靠它找缆
|
||||
const GROUP: StringName = &"zipline"
|
||||
## 缆绳截面的边数。缆很细,6 边足够,还省顶点
|
||||
const RING_SEGMENTS: int = 6
|
||||
|
||||
@export_group("缆绳")
|
||||
## 中点下垂深度(米)。0 = 绷直。真实滑索是绷紧的,所以别调太大
|
||||
@export_range(0.0, 12.0, 0.05) var sag: float = 1.2
|
||||
## 沿缆采样段数。越高越圆滑,也越费顶点
|
||||
@export_range(8, 128, 1) var segments: int = 48
|
||||
## 缆绳半径(米)
|
||||
@export_range(0.01, 0.3, 0.005) var radius: float = 0.05
|
||||
## 缆绳材质。留空则用内置的深色金属
|
||||
@export var cable_material: Material
|
||||
|
||||
@export_group("乘坐")
|
||||
## 人挂在缆下方多深(米)。约等于「双手举过头顶抓住缆」的高度
|
||||
@export var hang_offset: float = 1.15
|
||||
## 玩家离缆多近能按 E 挂上(米)。给得宽一点:缆悬在头顶时也算「够得着」
|
||||
@export var attach_radius: float = 4.0
|
||||
## 沿缆最高速度(米/秒)
|
||||
@export var max_speed: float = 18.0
|
||||
## 重力沿缆方向的缩放。1 = 完全按坡度加速,调小则「黏」一些
|
||||
@export var gravity_scale: float = 1.0
|
||||
## 按住后退键时的刹车减速度(米/秒²)
|
||||
@export var brake_accel: float = 14.0
|
||||
## 剩余行程短于此值就不让挂(米)。否则刚挂上就到头,像被弹开
|
||||
@export var min_ride: float = 3.0
|
||||
|
||||
@onready var anchor_a: Marker3D = $AnchorA
|
||||
@onready var anchor_b: Marker3D = $AnchorB
|
||||
|
||||
## 缆绳网格节点,_ready() 里现造
|
||||
var _cable: MeshInstance3D = null
|
||||
## 缆绳总长(米)。生成网格时顺带算出来,玩家用它把速度换算成 t 的推进量
|
||||
var length: float = 0.0
|
||||
## 编辑器里用来比对「锚点/参数变了没有」,避免每帧重建网格
|
||||
var _editor_sig: String = ""
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
add_to_group(GROUP)
|
||||
_cable = MeshInstance3D.new()
|
||||
_cable.name = "Cable"
|
||||
add_child(_cable)
|
||||
_rebuild_cable()
|
||||
# 编辑器下开着 _process,拖动锚点能立刻看到缆绳跟着变;运行时不需要
|
||||
set_process(Engine.is_editor_hint())
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if anchor_a == null or anchor_b == null:
|
||||
return
|
||||
# 只在签名变化时重建 —— SurfaceTool 生成网格不便宜,不能每帧做
|
||||
var sig := "%s|%s|%.3f|%d|%.4f" % [anchor_a.position, anchor_b.position, sag, segments, radius]
|
||||
if sig == _editor_sig:
|
||||
return
|
||||
_editor_sig = sig
|
||||
_rebuild_cable()
|
||||
|
||||
|
||||
# ---------------- 对外接口(玩家控制器只读这几个) ----------------
|
||||
|
||||
## 缆上参数 t∈[0,1] 处的世界坐标。
|
||||
func sample(t: float) -> Vector3:
|
||||
var a := anchor_a.global_position
|
||||
var b := anchor_b.global_position
|
||||
return a.lerp(b, t) - Vector3.UP * (sag * 4.0 * t * (1.0 - t))
|
||||
|
||||
|
||||
## 缆上参数 t 处的世界方向(单位向量,指向 B)。
|
||||
## 用解析导数而不是前后差分:差分在 t=0/1 处要越界取样,端点会偏。
|
||||
func tangent(t: float) -> Vector3:
|
||||
var d := (anchor_b.global_position - anchor_a.global_position) - Vector3.UP * (sag * 4.0 * (1.0 - 2.0 * t))
|
||||
return d.normalized()
|
||||
|
||||
|
||||
## 乘坐时人应该待的世界坐标:缆点再往下挂 hang_offset。
|
||||
func ride_point(t: float) -> Vector3:
|
||||
return sample(t) - Vector3.UP * hang_offset
|
||||
|
||||
|
||||
## 找出世界坐标 pos 在缆上最近的一点,供玩家「就近吸附」。
|
||||
## 返回 { t, point, distance, dir, remaining }:
|
||||
## dir +1 表示往 B 滑、-1 表示往 A 滑(永远是往低的那端)
|
||||
## remaining 沿 dir 方向还剩多少米
|
||||
func closest_point(pos: Vector3) -> Dictionary:
|
||||
var best_t := 0.0
|
||||
var best_d := INF
|
||||
for i in range(segments + 1):
|
||||
var t := float(i) / float(segments)
|
||||
var d := sample(t).distance_squared_to(pos)
|
||||
if d < best_d:
|
||||
best_d = d
|
||||
best_t = t
|
||||
var dir := _downhill_dir()
|
||||
return {
|
||||
"t": best_t,
|
||||
"point": sample(best_t),
|
||||
"distance": sqrt(best_d),
|
||||
"dir": dir,
|
||||
"remaining": absf((1.0 if dir > 0 else 0.0) - best_t) * length,
|
||||
}
|
||||
|
||||
|
||||
# ---------------- 内部 ----------------
|
||||
|
||||
## 低的那一端才是出口 —— 滑索只会往下滑。两端等高时退回 A→B。
|
||||
func _downhill_dir() -> int:
|
||||
return 1 if anchor_b.global_position.y <= anchor_a.global_position.y else -1
|
||||
|
||||
|
||||
## 世界曲线转成本地坐标:缆绳网格是本节点的子节点,顶点必须落在局部空间里。
|
||||
func _local_sample(t: float) -> Vector3:
|
||||
return to_local(sample(t))
|
||||
|
||||
|
||||
## 把下垂曲线扫成一根管子。用三角形带:每个截面 RING_SEGMENTS+1 个顶点,
|
||||
## 多出来的那个和首顶点重合,接缝处法线才连续、不会裂开一条缝。
|
||||
func _rebuild_cable() -> void:
|
||||
if _cable == null or anchor_a == null or anchor_b == null:
|
||||
return
|
||||
var st := SurfaceTool.new()
|
||||
st.begin(Mesh.PRIMITIVE_TRIANGLE_STRIP)
|
||||
var prev := _local_sample(0.0)
|
||||
length = 0.0
|
||||
for i in range(segments + 1):
|
||||
var t := float(i) / float(segments)
|
||||
var p := _local_sample(t)
|
||||
if i > 0:
|
||||
length += p.distance_to(prev)
|
||||
prev = p
|
||||
# 截面参考轴取「切线 × 世界上方向」。切线接近竖直时叉乘会退化成零向量,
|
||||
# 这时换 RIGHT 兜底,否则 normalize() 会吐 NaN 把整根缆搅烂。
|
||||
var tan := (_local_sample(minf(t + 0.01, 1.0)) - _local_sample(maxf(t - 0.01, 0.0))).normalized()
|
||||
var n := tan.cross(Vector3.UP)
|
||||
if n.length_squared() < 0.0001:
|
||||
n = tan.cross(Vector3.RIGHT)
|
||||
n = n.normalized()
|
||||
var binormal := n.cross(tan).normalized()
|
||||
for j in range(RING_SEGMENTS + 1):
|
||||
var ang := TAU * float(j) / float(RING_SEGMENTS)
|
||||
var off := n * cos(ang) + binormal * sin(ang)
|
||||
st.set_normal(off)
|
||||
st.add_vertex(p + off * radius)
|
||||
_cable.mesh = st.commit()
|
||||
var mat: Material = cable_material
|
||||
if mat == null:
|
||||
var std := StandardMaterial3D.new()
|
||||
std.albedo_color = Color(0.14, 0.15, 0.18)
|
||||
std.metallic = 0.85
|
||||
std.roughness = 0.3
|
||||
# 三角形带的绕序不保证,直接双面,省得算错朝向
|
||||
std.cull_mode = BaseMaterial3D.CULL_DISABLED
|
||||
mat = std
|
||||
_cable.material_override = mat
|
||||
@@ -0,0 +1 @@
|
||||
uid://ddh4pqdknx8yf
|
||||
@@ -0,0 +1,12 @@
|
||||
[gd_scene format=3 uid="uid://12qonfnowpas"]
|
||||
|
||||
[ext_resource type="Script" uid="uid://ddh4pqdknx8yf" path="res://world/zipline/zipline.gd" id="1_pj3rq"]
|
||||
|
||||
[node name="Zipline" type="Node3D" unique_id=982324784]
|
||||
script = ExtResource("1_pj3rq")
|
||||
|
||||
[node name="AnchorA" type="Marker3D" parent="." unique_id=267373356]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 8, -24)
|
||||
|
||||
[node name="AnchorB" type="Marker3D" parent="." unique_id=1805183028]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2, 18)
|
||||
Reference in New Issue
Block a user