diff --git a/.workbuddy-ai/memory/2026-09-24.md b/.workbuddy-ai/memory/2026-09-24.md new file mode 100644 index 0000000..2bacdbb --- /dev/null +++ b/.workbuddy-ai/memory/2026-09-24.md @@ -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` 里加新状态。 diff --git a/.workbuddy-ai/memory/MEMORY.md b/.workbuddy-ai/memory/MEMORY.md new file mode 100644 index 0000000..76190f2 --- /dev/null +++ b/.workbuddy-ai/memory/MEMORY.md @@ -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` 开头可被测试运行器兜底清理。 diff --git a/character/player/player.gd b/character/player/player.gd index e94f870..44c38b9 100644 --- a/character/player/player.gd +++ b/character/player/player.gd @@ -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,直接跳过。 diff --git a/character/player/player.tscn b/character/player/player.tscn index 7d36068..f5eeaef 100644 --- a/character/player/player.tscn +++ b/character/player/player.tscn @@ -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 diff --git a/project.godot b/project.godot index c142958..c104f21 100644 --- a/project.godot +++ b/project.godot @@ -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] diff --git a/tests/test_zipline.gd b/tests/test_zipline.gd new file mode 100644 index 0000000..80a3886 --- /dev/null +++ b/tests/test_zipline.gd @@ -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 diff --git a/tests/test_zipline.gd.uid b/tests/test_zipline.gd.uid new file mode 100644 index 0000000..962dfd9 --- /dev/null +++ b/tests/test_zipline.gd.uid @@ -0,0 +1 @@ +uid://b36lbd1qvlebs diff --git a/world/main.tscn b/world/main.tscn index 1ca8f23..89214ce 100644 --- a/world/main.tscn +++ b/world/main.tscn @@ -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")] diff --git a/world/zipline/zipline.gd b/world/zipline/zipline.gd new file mode 100644 index 0000000..6049fa2 --- /dev/null +++ b/world/zipline/zipline.gd @@ -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 diff --git a/world/zipline/zipline.gd.uid b/world/zipline/zipline.gd.uid new file mode 100644 index 0000000..8967632 --- /dev/null +++ b/world/zipline/zipline.gd.uid @@ -0,0 +1 @@ +uid://ddh4pqdknx8yf diff --git a/world/zipline/zipline.tscn b/world/zipline/zipline.tscn new file mode 100644 index 0000000..4b176fa --- /dev/null +++ b/world/zipline/zipline.tscn @@ -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)