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
+26
View File
@@ -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` 里加新状态。
+24
View File
@@ -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
View File
@@ -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,直接跳过。
+10
View File
@@ -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
+5
View File
@@ -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]
+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
+41
View File
@@ -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")]
+180
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://ddh4pqdknx8yf
+12
View File
@@ -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)