181 lines
7.0 KiB
GDScript
181 lines
7.0 KiB
GDScript
@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_dir := (_local_sample(minf(t + 0.01, 1.0)) - _local_sample(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
|