Files
agame/world/zipline/zipline.gd
T
2026-10-03 18:48:04 +08:00

181 lines
7.3 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 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] 处的世界坐标。
## 曲线先在**本地空间**算完再转世界:sag 是本地单位,父节点一有缩放整条缆要跟着一起缩,
## 形状才不走样(混用 global 锚点 + 未缩放的 sag,下垂比例会随父节点缩放而变)。
func sample(t: float) -> Vector3:
return to_global(_curve_local(t))
## 缆上参数 t 处的世界方向(单位向量,指向 B)。
## 用解析导数而不是前后差分:差分在 t=0/1 处要越界取样,端点会偏。
func tangent(t: float) -> Vector3:
var d := (anchor_b.position - anchor_a.position) - Vector3.UP * (sag * 4.0 * (1.0 - 2.0 * t))
return (global_transform.basis * d).normalized()
## 乘坐时人应该待的世界坐标:缆点再往下挂 hang_offset。
func ride_point(t: float) -> Vector3:
return to_global(_curve_local(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
## 下垂曲线在**本节点局部空间**的点。sag 是本地单位,所以父节点缩放时整条缆一起缩、形状不变。
func _curve_local(t: float) -> Vector3:
return anchor_a.position.lerp(anchor_b.position, t) - Vector3.UP * (sag * 4.0 * t * (1.0 - 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 := _curve_local(0.0)
length = 0.0
for i in range(segments + 1):
var t := float(i) / float(segments)
var p := _curve_local(t)
if i > 0:
length += p.distance_to(prev)
prev = p
# 截面参考轴取「切线 × 世界上方向」。切线接近竖直时叉乘会退化成零向量,
# 这时换 RIGHT 兜底,否则 normalize() 会吐 NaN 把整根缆搅烂。
var tan_dir := (_curve_local(minf(t + 0.01, 1.0)) - _curve_local(maxf(t - 0.01, 0.0))).normalized()
var n := tan_dir.cross(Vector3.UP)
if n.length_squared() < 0.0001:
n = tan_dir.cross(Vector3.RIGHT)
n = n.normalized()
var binormal := n.cross(tan_dir).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