@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