diff --git a/.workbuddy-ai/memory/2026-10-03.md b/.workbuddy-ai/memory/2026-10-03.md index 78c8335..41b6418 100644 --- a/.workbuddy-ai/memory/2026-10-03.md +++ b/.workbuddy-ai/memory/2026-10-03.md @@ -123,6 +123,56 @@ **未做**:联机同步(第 2 步)。现在物品位置只有持有者本地在写,远端玩家看不到箱子被抱走 —— 需要给物品加 `MultiplayerSynchronizer` + 跟随持有者的 authority 切换 + 抢占仲裁。 +## 补上:第一人称看得见胳膊 + 抱起物品联机同步 + +用户要求「玩家需要看到自己胳膊,联机也需要同步」,并给了 `CLAUDE.md`: +**「游戏内测试交给用户来,不要自己做」** —— 已记进 MEMORY.md,之后不再自己跑 `project_run` 截图验证。 + +**第一人称留胳膊**:`player.gd` 的 `_hide_body_keep_arms()` 用栈遍历 Body 下所有 MeshInstance3D, +按 `arm_` 前缀筛出胳膊留着、其余藏掉;一个都没筛到(换模型改名)就退化成整个 `body.visible = false`。 + +**联机同步**(关键设计:只同步一个整数): +- 新增 `world/items/carryable_sync.tres`(同步 `.:position` / `.:rotation` / `.:carrier_peer`), + `carryable_box.tscn` 里加 `Sync`(MultiplayerSynchronizer)节点。 +- `Carryable.carrier_peer` 带 setter:非 0 就 `freeze = true` + `collision_layer = 0`; + 为 0 时 `freeze = not multiplayer.is_server()`(只有服务器模拟,其余端跟着同步走)。 +- **抱着时位置不走同步**:`_physics_process` 里 `_find_carrier_socket()`(玩家节点名 = peer id,组 `net_player`) + 把物品摆到持有者抱持点上。每端都这么算 → 天然一致,也就没有「写位置 / 同步写回来」打架的问题。 +- **权威永远在服务器**:`request_pickup(peer_id)` / `request_release(velocity)` 两个 RPC, + 客户端 `rpc_id(1, ...)`、单机(is_server)直接调;服务器只改 `carrier_peer`,放下时顺手写 `linear_velocity`。 +- 玩家侧:`_pick_up()` 乐观认领 + `pickup_confirm_time`(0.6s)超时松手;`_release_throw()` 把初速度发给服务器。 + +**顺手发现(重要,未改,等用户确认)**:`player.tscn` 的根节点 `Player` 现在带 +`transform = Transform3D(0.2, 0, 0, 0, 0.2, 0, 0, 0, 0.2, 0, 0, 0)` —— 整只玩家被缩到 20% +(胶囊 0.36m、相机 0.32m、模型 0.23m),而且这个缩放会漏给「挂在编辑中场景根下」的节点: +`tests/test_zipline.gd` 就是这么挂滑索的,于是 `test_tangent_points_from_a_to_b` 红了 +(实测 tangent(0)·along = 0.888 < 0.9;zipline.gd 的 `sample()` 用全局锚点 lerp 却加**未缩放的 sag**, +缩放一进来形状就偏了)。这行是我读 scene 之后才出现的,来源是提交 `6f3c89a 抱起物品`(用户提交的工作区)。 + +`test_run` **40/41**(唯一红的就是上面那条滑索测试)。 + +## 用户确认:玩家缩放 0.2 是有意的 → 滑索改成缩放无关 + +用户明确「就是想将玩家缩小到 0.2 倍」,所以那行不改。但滑索那条测试还得救,而且它暴露的是**真 bug**: + +- `zipline.gd` 的 `sample()` 用 `anchor_a.global_position.lerp(anchor_b.global_position, t)` 再减 + `UP * sag * 4t(1-t)` —— 前半截跟着父节点缩放、后半截不跟,父节点一有缩放曲线形状就偏。 + `tangent()` / `ride_point()` 同样的混用。 +- 改成**先在本地空间算曲线**(`_curve_local()`,原来的 `_local_sample` 从「把世界点转回本地」变成直接算本地), + `sample()`/`ride_point()` 用 `to_global()` 转世界,`tangent()` 用 `global_transform.basis * d` 转方向。 + 缆绳网格本来就在本地空间生成,现在少了一次多余的 `to_local`。 +- 测试夹具原来挂在**编辑中场景根**下,于是玩家根的 0.2 缩放漏进滑索。改成挂在 + `Engine.get_main_loop().root` 下的中性父节点(`_make_holder()`),夹具不再受编辑中场景影响。 +- 新增 `test_shape_survives_parent_scale`:故意套一个 0.2 缩放的父节点,验 tangent 不歪、 + 中点下垂 = sag × 0.2。这条把刚踩的坑钉住了。 + +`test_run` **42/42**。 + +**留给用户决定的尺度问题**(我只报告,没改):玩家缩到 0.2 之后,世界单位的参数都还是老尺度 —— +`walk_speed 5 / sprint 8.5 / jump 5`(= 14 身高/秒)、`pickup_radius 3.0`(8 身高)、 +滑索 `attach_radius 4.0` / `hang_offset 1.15`(人会吊在缆下方 3 个身高)、物品 `throw_speed 5~14`、 +示例箱子 0.6m(比 0.36m 的玩家还高)。要不要按 0.2 一起缩,等用户拍板。 + ## 给「抱起物品系统」出了方案(未动代码) 用户要新功能:玩家抱起物品,先要方案。我在对话里给了设计,没有写文件。 diff --git a/.workbuddy-ai/memory/MEMORY.md b/.workbuddy-ai/memory/MEMORY.md index 71a7ea1..86251cc 100644 --- a/.workbuddy-ai/memory/MEMORY.md +++ b/.workbuddy-ai/memory/MEMORY.md @@ -2,6 +2,17 @@ ## 工程约定 +- **`CLAUDE.md` 里写着「游戏内测试交给用户来,不要自己做」** —— 别用 `project_run` + 截图去自己验游戏, + 编辑器里的 `test_run` 照常跑。画面问题让用户看,或者用数值探针验。 +- **玩家根节点 `Player` 有意缩放 0.2**(用户明确说「就是想将玩家缩小到 0.2 倍」):胶囊 0.36m、相机 0.32m、 + 模型 0.23m。挂在玩家下的东西(`CameraRig/CarrySocket`、`Body/*`)**本地坐标会自动跟着缩**,所以抱持点不用改; + 但**世界单位**的参数没跟着缩:`walk_speed/sprint_speed/jump_velocity`、`pickup_radius`、 + 滑索的 `attach_radius/hang_offset`、物品的 `throw_speed_*`、示例箱子尺寸(0.6m 比玩家还高)都还是老尺度。 +- **滑索的曲线在「本节点局部空间」算**(`_curve_local()`),`sample/tangent/ride_point` 再转成世界 —— + 父节点带缩放时整条缆一起缩、形状才不走样(早前混用 global 锚点 lerp + 未缩放的 sag,父节点一缩放 + tangent 就歪 27°)。测试夹具同理**别挂在编辑中场景根下**:玩家的 0.2 缩放会把滑索几何带偏 + (实测中点下垂 1.2 → 0.24)。滑索测试现在挂在编辑器根节点下的中性父节点里,另有一条 + `test_shape_survives_parent_scale` 专门钉住缩放行为。 - 玩家控制器只认「运动结果」,不认识任何动画片段名;表现层脚本 `player_animator.gd` 直接挂在 `Body/AnimationPlayer` 上(`animation_player` 留空即自绑自身,所以两者是同一个节点)。 新增动作状态改 `ANIM_CLIPS`,不要往 `player.gd` 里塞片段名。 @@ -89,12 +100,19 @@ **参数按「世界方向」语义**(`rest_basis.inverse() * world * rest_basis` 换算到骨骼本地空间)—— 直接写骨骼本地欧拉角会得到「手往上后方摆」这种莫名其妙的结果,换模型必错。 默认 swing 40 / tuck 50 / roll 0 / elbow 15 / stretch 1.25 → 手落在模型本地 (±1.17, 0.08, 0.95)。 -- 本地玩家第一人称藏了自己的模型(`hide_own_body`),所以**自己看不到自己的胳膊**,晃动只有别的玩家能看到; - 想自己看就把 `hide_own_body` 关掉并把 `eye_height` 降下来。 -- **未做**:联机同步。现在物品位置只有持有者本地在写,远端看不到箱子被抱走 —— - 要给物品加 `MultiplayerSynchronizer`、authority 随持有者切换、再加抢占仲裁。 +- 本地玩家第一人称只藏身体、**留下两条胳膊**(`_hide_body_keep_arms()` 按 `arm_` 前缀筛,找不到就整个藏掉)—— + 抱东西时得看得见自己的手。 +- **联机只同步 `carrier_peer` 一个整数**(物品自己带 `MultiplayerSynchronizer`,配置 `world/items/carryable_sync.tres`)。 + **抱着的时候位置根本不走同步**:每一端都从「持有者的抱持点」把物品摆出来(`Carryable._physics_process` → + `_find_carrier_socket()`,靠玩家节点名 = peer id、组 `net_player` 找到),结果天然一致, + 也避免了「持有者写位置 / 同步又把位置写回来」互相打架。空着的时候才同步 position/rotation。 +- **权威永远在服务器**(不切换):物品位置只由服务器模拟,其余端 `freeze = true` 跟着同步走; + 抱起/放下都是客户端 `rpc_id(1, ...)` 请求服务器,服务器改 `carrier_peer` 就完事。 +- 抱起是乐观认领 + 超时松手:`_pick_up()` 先把自己记成持有者,`pickup_confirm_time`(默认 0.6s)内 + 服务器没把 `carrier_peer` 改成自己就自动松手 —— 联机下被别人先抢走时会走到这一步。 - 测试 `tests/test_carry.gd`:`Carryable` / `CarryArms` 都标了 `@tool`,否则编辑器里是 placeholder、 方法调不动、`_ready` 不跑(组也加不上);改完脚本要 `filesystem_manage(op="scan")` 才重新解析。 + 玩家脚本不是 @tool,所以「第一人称留胳膊」那条测试查的是源码 + 模型里真有 `arm_*` mesh。 ## Godot 4 踩坑 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8ad9c70 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +游戏内测试交给用户来,不要自己做 \ No newline at end of file diff --git a/character/player/player.gd b/character/player/player.gd index a85dd8c..49df9e0 100644 --- a/character/player/player.gd +++ b/character/player/player.gd @@ -39,8 +39,8 @@ const JUMP_BUFFER_FRAMES: int = 6 @export_group("视角") ## 眼睛高度(米)。相机就架在这个高度上 —— 按碰撞胶囊定,跟模型高矮无关。 @export var eye_height: float = 1.6 -## 第一人称下藏掉自己的模型。不藏的话相机要么在模型里、要么贴着头顶,画面糊一片。 -## 想做成「看得见自己身体」的第一人称,就把这个关掉、并把 eye_height 降到模型头部高度。 +## 第一人称下藏掉自己的身体。**两条胳膊会留下来** —— 抱东西时得看得见自己的手。 +## 想连胳膊也看不见就把这个关掉(那样相机要么在模型里、要么贴着头顶,画面会糊一片)。 ## 只影响本机这一份 —— 远端玩家的模型照常显示。 @export var hide_own_body: bool = true ## 鼠标灵敏度(弧度/像素) @@ -73,6 +73,8 @@ const JUMP_BUFFER_FRAMES: int = 6 @export var throw_charge_time: float = 0.8 ## 扔出去时额外叠上玩家自身的速度(跑着扔会飞更远) @export var throw_inherits_velocity: bool = true +## 等服务器确认抱起的宽限时间(秒)。联机下被别人先抢走时,超时就自动松手 +@export var pickup_confirm_time: float = 0.6 @onready var body: Node3D = $Body ## 眼睛分两层转:CameraRig 绕竖直轴转(yaw),Camera 原地抬头低头(pitch)。 @@ -83,9 +85,9 @@ const JUMP_BUFFER_FRAMES: int = 6 @onready var _health_label: Label3D = $HealthLabel ## 滑索操作提示,同样在 player.tscn 里配好,默认隐藏 @onready var _hint: Label3D = $InteractHint -## 抱持点:抱起来的物品每物理帧摆到它上面 -@onready var _carry_socket: Node3D = $CameraRig/CarrySocket -## 抱东西时的胳膊表现层(挂 carry_arms.gd):只收「抱没抱、走多快」 +## 抱东西时的胳膊表现层(挂 carry_arms.gd):只收「抱没抱、走多快」。 +## 抱持点(CameraRig/CarrySocket)不在这里引用 —— 物品自己按名字找持有者的抱持点, +## 这样每端算出来的位置都一样,联机不用同步物品位置。 @onready var _carry_arms: CarryArms = $Body/CarryArms ## 动画状态机(挂在 Body/AnimationPlayer 上)。只把「运动结果」交给它,也只知道它会自己播动画。 @@ -110,12 +112,14 @@ var _zip_speed: float = 0.0 ## 挂上瞬间「人相对缆」的偏移,随后衰减到 0 —— 吸附是滑过去的,不是瞬移 var _zip_grab_offset: Vector3 = Vector3.ZERO -## 手上抱着的物品。非 null 时每物理帧把它摆到抱持点上。 -## 只在权威端写;远端玩家的物品位置由物品自己的 Sync 复制过来。 +## 手上抱着的物品。位置由物品自己跟住抱持点(见 Carryable),玩家不插手 —— +## 这样每端算出来的位置都一样,联机不用同步物品位置。 var _carried: Carryable = null ## 长按 Q 的蓄力进度 0~1 var _throw_charge: float = 0.0 var _throw_charging: bool = false +## 等服务器确认抱起的剩余时间。服务器没给(被别人先抢了)就自己松手 +var _pickup_wait: float = 0.0 func _ready() -> void: @@ -134,7 +138,7 @@ func _ready() -> void: return camera_rig.position.y = eye_height if hide_own_body: - body.visible = false + _hide_body_keep_arms() _yaw = camera_rig.rotation.y _pitch = clampf(camera.rotation.x, deg_to_rad(pitch_min_deg), deg_to_rad(pitch_max_deg)) _apply_look() @@ -143,6 +147,25 @@ func _ready() -> void: _refresh_health_label() +## 第一人称:藏掉身体,但**把两条胳膊留下** —— 抱东西时得看得见自己的手。 +## 模型各部位是独立 mesh,按名字挑出来即可;名字对不上(换模型了)就退化成整个藏掉。 +func _hide_body_keep_arms() -> void: + var stack: Array[Node] = [body] + var kept := 0 + while not stack.is_empty(): + var node: Node = stack.pop_back() + for child in node.get_children(): + stack.append(child) + var mesh := child as MeshInstance3D + if mesh == null: + continue + var is_arm := str(mesh.name).begins_with("arm_") + mesh.visible = is_arm + kept += 1 if is_arm else 0 + if kept == 0: + body.visible = false + + func _unhandled_input(event: InputEvent) -> void: if event is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED: _yaw -= event.relative.x * mouse_sensitivity @@ -218,11 +241,15 @@ func _physics_process(delta: float) -> void: else: _hint.visible = false - # 抱着东西:每帧把它摆到抱持点上,并推进 Q 的蓄力。 - # 物品的位置由持有者写 —— 远端玩家的那份由物品自己的 Sync 复制过去。 + # 抱着东西:推进 Q 的蓄力。物品位置不归玩家管 —— 它自己跟住抱持点(见 Carryable), + # 每端都这么算,所以联机不用同步物品位置。 if _carried != null: - _carried.apply_hold_transform(_carry_socket) - if _throw_charging: + if _carried.carrier_peer != multiplayer.get_unique_id(): + # 服务器还没确认,或者被别人先抢走了:给一小段时间,超时就松手 + _pickup_wait -= delta + if _pickup_wait <= 0.0: + _carried = null + elif _throw_charging: _throw_charge = minf(1.0, _throw_charge + delta / maxf(throw_charge_time, 0.01)) # 胳膊表现层只收「抱没抱、走多快」,摆成什么样是它自己的事 _carry_arms.set_carrying( @@ -310,15 +337,20 @@ func _find_carryable() -> Carryable: ## 抱起最近的可抱物品。手上已经有东西、或者正挂在缆上都不抱。 +## 实际「谁抱到了」由服务器仲裁(先到先得),这里只是发起请求并乐观地先认领, +## 服务器不给就在 _physics_process 里超时松手。 func _pick_up() -> void: if _carried != null or _zipline != null: return var item := _find_carryable() if item == null: return - item.begin_carry(self) - item.apply_hold_transform(_carry_socket) _carried = item + _pickup_wait = pickup_confirm_time + if multiplayer.is_server(): + item.request_pickup(multiplayer.get_unique_id()) + else: + item.request_pickup.rpc_id(1, multiplayer.get_unique_id()) ## 松手扔出:方向就是视线方向,速度按蓄力插值(轻点也能扔出去,只是近)。 @@ -334,7 +366,11 @@ func _release_throw() -> void: if throw_inherits_velocity: launch += velocity _throw_charge = 0.0 - item.end_carry(launch) + # 出手速度交给服务器:它收回模拟权,让物品带着这个速度接着飞 + if multiplayer.is_server(): + item.request_release(launch) + else: + item.request_release.rpc_id(1, launch) # ---------------- 滑索 ---------------- diff --git a/tests/test_carry.gd b/tests/test_carry.gd index 44e3df9..82158a7 100644 --- a/tests/test_carry.gd +++ b/tests/test_carry.gd @@ -3,12 +3,14 @@ extends McpTestSuite ## 回归测试:抱起物品系统的契约。 ## ## 谁能被抱由**物品自己**决定(挂 carryable.gd + 进 carryable 组),玩家只管找/抱/扔; -## 抱持偏移和投掷速度也写在物品上。这里盯住这几条契约,改物品或改玩家时先让测试红一次。 +## 抱持偏移和投掷速度也写在物品上。联机只同步 `carrier_peer` 一个整数 —— +## 抱着的时候位置不走同步,每端都从持有者的抱持点算出来,这几条契约坏了联机就会「各看各的」。 const PLAYER_SCENE_PATH := "res://character/player/player.tscn" const ITEM_SCENE_PATH := "res://world/items/carryable_box.tscn" const CARRY_ACTION := "carry" const THROW_ACTION := "throw_carry" +const HOLDER_PEER := 7 func suite_name() -> String: @@ -33,6 +35,28 @@ func _into_tree(node: Node) -> Node: return node +func _spawn_item() -> Carryable: + var packed := load(ITEM_SCENE_PATH) as PackedScene + if packed == null: + return null + return _into_tree(packed.instantiate()) as Carryable + + +## 造一个「假持有者」:玩家节点在世界里以 peer id 命名、进 net_player 组,抱持点挂在相机架上 +func _fake_holder() -> Node3D: + var holder := Node3D.new() + holder.name = str(HOLDER_PEER) + holder.add_to_group(Carryable.PLAYER_GROUP) + var rig := Node3D.new() + rig.name = "CameraRig" + holder.add_child(rig) + var socket := Marker3D.new() + socket.name = "CarrySocket" + socket.position = Vector3(0.0, 1.0, -2.0) + rig.add_child(socket) + return _into_tree(holder) as Node3D + + func test_carry_actions_exist() -> void: _ensure_input_map() # 动作没了按键就完全没反应,而且不报错 —— 这种静默失效必须由测试兜住 @@ -56,63 +80,111 @@ func test_player_scene_has_carry_nodes() -> void: "Body/CarryArms 没挂 carry_arms.gd,胳膊不会抱东西") -func test_example_item_is_carryable() -> void: - var packed := load(ITEM_SCENE_PATH) as PackedScene - assert_true(packed != null, "%s 未能加载 —— 可抱物品的示例没了" % ITEM_SCENE_PATH) +## 第一人称要看得见自己的胳膊。 +## player.gd 不是 @tool,在编辑器里是 placeholder、方法调不动,所以这里查两件事: +## 代码里确实按 `arm_` 前缀把胳膊留下来,且模型里真的有这些独立 mesh(换模型改名会红)。 +func test_first_person_keeps_arms_visible() -> void: + var file := FileAccess.open("res://character/player/player.gd", FileAccess.READ) + assert_true(file != null, "读不到 player.gd") + if file == null: + return + var source := file.get_as_text() + assert_true(source.contains("_hide_body_keep_arms"), "player.gd 里没有「第一人称保留胳膊」的逻辑") + assert_true(source.contains("begins_with(\"arm_\")"), + "藏身体时没有按 arm_ 前缀把胳膊留下来,第一人称会看不到自己的手") + var packed := load(PLAYER_SCENE_PATH) as PackedScene if packed == null: return - var item := packed.instantiate() - assert_true(item is Carryable, "示例物品根节点不是 Carryable(%s)" % item.get_class()) - if not item is Carryable: - track(item) + var player := packed.instantiate() + track(player) + var skeleton := player.get_node_or_null("Body/mouse/MouseRig/Skeleton3D") + assert_true(skeleton != null, "找不到骨架,模型结构变了?") + if skeleton == null: return - var carryable := item as Carryable - _into_tree(carryable) - assert_true(carryable.is_in_group(Carryable.GROUP), + var arms := 0 + var others := 0 + for child in skeleton.get_children(): + var mesh := child as MeshInstance3D + if mesh == null: + continue + if str(mesh.name).begins_with("arm_"): + arms += 1 + else: + others += 1 + assert_true(arms > 0, "模型里没有 arm_* 网格,第一人称看不到手") + assert_true(others > 0, "模型里没有别的部位网格,藏身体就无从谈起") + + +func test_example_item_is_carryable() -> void: + var item := _spawn_item() + assert_true(item != null, "%s 实例化失败 —— 可抱物品的示例没了" % ITEM_SCENE_PATH) + if item == null: + return + assert_true(item.is_in_group(Carryable.GROUP), "可抱物品没进 %s 组,玩家就找不到它" % Carryable.GROUP) - assert_true(carryable.get_node_or_null("Collision") is CollisionShape3D, + assert_true(item.get_node_or_null("Collision") is CollisionShape3D, "示例物品没有碰撞体,扔出去会穿地") +## 联机靠 Sync 复制 position / rotation / carrier_peer;漏了 carrier_peer 别人就不知道被抱走了。 +func test_item_sync_config_covers_carry_state() -> void: + var item := _spawn_item() + if item == null: + return + var sync := item.get_node_or_null("Sync") as MultiplayerSynchronizer + assert_true(sync != null, "示例物品没有 MultiplayerSynchronizer,联机看不到别人抱走它") + if sync == null or sync.replication_config == null: + return + var paths: Array[String] = [] + for path in sync.replication_config.get_properties(): + paths.append(str(path)) + for required in [".:carrier_peer", ".:position", ".:rotation"]: + assert_true(paths.has(required), "同步配置里少了 %s(实际 %s)" % [required, paths]) + + func test_carry_freezes_body_and_restores_on_release() -> void: - var item := _into_tree((load(ITEM_SCENE_PATH) as PackedScene).instantiate()) as Carryable - assert_true(item != null, "示例物品实例化失败") + var item := _spawn_item() if item == null: return var layer := item.collision_layer assert_true(item.can_be_carried(), "新物品应该是可以抱的") - var holder := Node3D.new() - track(holder) - item.begin_carry(holder) + item.request_pickup(HOLDER_PEER) + assert_eq(item.carrier_peer, HOLDER_PEER, "抱起请求没记到 carrier_peer 上") assert_true(not item.can_be_carried(), "抱起来之后不该还能被别人抱走") assert_true(item.freeze, "抱起来要冻结物理,否则会和抱着它的人打架") assert_eq(item.collision_layer, 0, "抱起来要清掉碰撞层,否则物品会把玩家顶飞") - item.end_carry(Vector3(1.0, 2.0, 3.0)) + item.request_release(Vector3(1.0, 2.0, 3.0)) + assert_eq(item.carrier_peer, 0, "放下之后 carrier_peer 要清零") assert_true(item.can_be_carried(), "放下之后应该又能被抱") - assert_true(not item.freeze, "放下要恢复物理") + assert_true(not item.freeze, "单机 / 服务器端放下要恢复物理模拟") assert_eq(item.collision_layer, layer, "放下要恢复原来的碰撞层") assert_true(item.linear_velocity.is_equal_approx(Vector3(1.0, 2.0, 3.0)), "放下时要继承扔出去的速度,实际 %s" % item.linear_velocity) -func test_hold_transform_snaps_to_socket() -> void: - var item := _into_tree((load(ITEM_SCENE_PATH) as PackedScene).instantiate()) as Carryable - if item == null: +## 抱着的时候位置不走同步:每端都从持有者的抱持点算。这里用一个假持有者验这件事。 +func test_carried_item_follows_holder_socket() -> void: + var item := _spawn_item() + var holder := _fake_holder() + if item == null or holder == null: return - var socket := Marker3D.new() - track(socket) - socket.position = Vector3(1.0, 2.0, -3.0) - socket.rotation = Vector3(0.0, PI * 0.5, 0.0) - var root: Node = Engine.get_main_loop().root - root.add_child(socket) - item.apply_hold_transform(socket) + item.request_pickup(HOLDER_PEER) + item._physics_process(0.016) + var socket := holder.get_node("CameraRig/CarrySocket") as Node3D var expected := socket.global_position + socket.global_basis * item.hold_offset assert_true(item.global_position.distance_to(expected) < 0.001, - "物品没摆到抱持点上:实际 %s,期望 %s" % [item.global_position, expected]) + "物品没跟住持有者的抱持点:实际 %s,期望 %s" % [item.global_position, expected]) + # 松开之后就不该再跟了 + item.request_release(Vector3.ZERO) + var after_release := item.global_position + holder.position += Vector3(10.0, 0.0, 0.0) + item._physics_process(0.016) + assert_true(item.global_position.distance_to(after_release) < 0.001, + "已经放手了还在跟持有者跑") func test_throw_speed_scales_with_charge() -> void: - var item := _into_tree((load(ITEM_SCENE_PATH) as PackedScene).instantiate()) as Carryable + var item := _spawn_item() if item == null: return assert_true(absf(item.throw_speed(0.0) - item.throw_speed_min) < 0.001, diff --git a/tests/test_zipline.gd b/tests/test_zipline.gd index 701ebff..73c4f20 100644 --- a/tests/test_zipline.gd +++ b/tests/test_zipline.gd @@ -19,6 +19,8 @@ const PLAYER_SCENE_PATH := "res://character/player/player.tscn" const INTERACT_ACTION := "interact" ## 挂在编辑中场景下。前缀 _McpTest 是测试运行器兜底清理认的标记 const TEST_NODE_NAME := "_McpTestZipline" +## 测试自己造的父节点:不带任何变换,免得编辑中场景的缩放漏进来 +const TEST_PARENT_NAME := "_McpTestZiplineHolder" var _zip: Zipline = null @@ -40,10 +42,13 @@ func suite_setup(_ctx: Dictionary) -> void: ## 每个测试拿一个干净实例:有些测试会故意篡改锚点,共享实例会让结果依赖执行顺序。 +## 挂在**编辑器根节点**下的测试父节点里,而不是编辑中场景根下 —— +## 滑索的几何是在它自己的空间里定义的,挂在场景根下会被那个场景的变换带偏 +## (player.tscn 的根节点就带 0.2 缩放,实测把中点下垂从 1.2 变成 0.24)。 func setup() -> void: _zip = _instantiate_zipline() if _zip != null: - EditorInterface.get_edited_scene_root().add_child(_zip) + Engine.get_main_loop().root.add_child(_make_holder(Vector3.ONE, _zip)) func teardown() -> void: @@ -127,6 +132,27 @@ func test_tangent_points_from_a_to_b() -> void: assert_gt(_zip.tangent(t).dot(along), 0.9, "tangent(%s) 没从 A 指向 B" % t) +## 父节点带缩放时,整条缆要跟着一起缩 —— 形状(下垂比例)不能走样。 +## 这里踩过:曲线用 global 锚点 lerp、却加未缩放的 sag,父节点一缩放形状就偏, +## 挂在带缩放的父节点下 tangent 会歪到 27°,直接把人往错方向送。 +func test_shape_survives_parent_scale() -> void: + var packed := load(ZIPLINE_SCENE_PATH) as PackedScene + if packed == null: + return + var zip := packed.instantiate() as Zipline + if zip == null: + return + zip.name = TEST_NODE_NAME + Engine.get_main_loop().root.add_child(_make_holder(Vector3(0.2, 0.2, 0.2), zip)) + var along := (zip.anchor_b.global_position - zip.anchor_a.global_position).normalized() + for t in [0.0, 0.5, 1.0]: + assert_gt(zip.tangent(t).dot(along), 0.9, "缩放父节点下 tangent(%s) 形状走样了" % t) + # 中点的世界下垂量应该是 sag × 父节点缩放 + var dip := zip.anchor_a.global_position.lerp(zip.anchor_b.global_position, 0.5).distance_to(zip.sample(0.5)) + assert_true(absf(dip - zip.sag * 0.2) < 0.01, + "缩放后中点下垂 %.4f,期望 sag×0.2 = %.4f" % [dip, zip.sag * 0.2]) + + ## 玩家挂索靠这个接口。返回的点必须在曲线上,distance 必须是真实欧氏距离。 func test_closest_point_lands_on_curve() -> void: var probe := _zip.sample(0.3) + Vector3(1.0, 1.0, 0.0) @@ -186,3 +212,13 @@ func _instantiate_zipline() -> Zipline: return null zip.name = TEST_NODE_NAME return zip + + +## 造一个父节点(可带缩放)并把 node 挂进去,返回那个父节点。测试用它把滑索和编辑中场景隔开。 +func _make_holder(scale_factor: Vector3, node: Node) -> Node3D: + var holder := Node3D.new() + holder.name = TEST_PARENT_NAME + holder.scale = scale_factor + holder.add_child(node) + track(holder) + return holder diff --git a/world/items/carryable.gd b/world/items/carryable.gd index 12329c7..64c45f8 100644 --- a/world/items/carryable.gd +++ b/world/items/carryable.gd @@ -7,22 +7,30 @@ extends RigidBody3D ## 和 Zipline 一样,手感参数放物品这一侧:不同物品可以有不同的抱持偏移、不同的投掷速度, ## 玩家只负责「找到它、把它摆到手上、扔出去」,不定义手感。 ## -## 用法:把这个脚本挂在物品根节点(RigidBody3D)上即可,别的什么都不用配。 +## 用法:把这个脚本挂在物品根节点(RigidBody3D)上,再给它加一个 MultiplayerSynchronizer +## (配置见 res://world/items/carryable_sync.tres,同步 position / rotation / carrier_peer)。 ## 参考示例见 res://world/items/carryable_box.tscn。 ## +## 联机只同步一个整数:`carrier_peer`(谁抱着)。 +## **抱着的时候位置根本不走同步** —— 每一端都从「持有者的抱持点」把物品摆出来, +## 结果天然一致,也省掉了「持有者写位置、同步又把位置写回来」的互相打架。 +## 空着的时候才靠 position / rotation 同步,而且只有服务器在模拟。 +## ## 标了 @tool 是为了能在编辑器里跑测试(非 @tool 脚本在编辑器里是 placeholder,方法调不动); ## 运行时行为完全一样。 ## 所有可抱物品都进这个组,玩家靠它找物品 const GROUP: StringName = &"carryable" +## 玩家节点在世界里以 peer id 命名并进了这个组,靠它找持有者的抱持点 +const PLAYER_GROUP: StringName = &"net_player" +## 抱持点在玩家场景里的相对路径 +const SOCKET_PATH: String = "CameraRig/CarrySocket" @export_group("抱持") ## 被抱时相对抱持点的偏移(米)。物品大小不同,靠它对准手心 @export var hold_offset: Vector3 = Vector3.ZERO ## 被抱时的朝向(度) @export var hold_rotation_deg: Vector3 = Vector3.ZERO -## 抱起来时冻结物理、并清掉碰撞层 —— 不清的话物品会把抱着它的人顶飞 -@export var freeze_while_carried: bool = true @export_group("投掷") ## 长按蓄满力后的出手速度(米/秒) @@ -30,8 +38,14 @@ const GROUP: StringName = &"carryable" ## 轻点时最低出手速度(米/秒) @export var throw_speed_min: float = 5.0 -## 现在被谁抱着。空 = 没人抱 -var carried_by: Node3D = null +## 谁抱着它(peer id),0 = 没人抱。 +## 由服务器改,通过物品自己的 MultiplayerSynchronizer 复制到各端;各端只读。 +var carrier_peer: int = 0: + set(value): + if carrier_peer == value: + return + carrier_peer = value + _update_simulation() var _saved_collision_layer: int = 1 @@ -39,30 +53,23 @@ var _saved_collision_layer: int = 1 func _ready() -> void: add_to_group(GROUP) _saved_collision_layer = collision_layer + _update_simulation() func can_be_carried() -> bool: - return carried_by == null + return carrier_peer == 0 -## 被抱起来:冻结物理、退出碰撞,之后由持有者每帧摆位置 -func begin_carry(holder: Node3D) -> void: - carried_by = holder - if freeze_while_carried: - freeze = true - collision_layer = 0 +## 抱着的时候跟着持有者的抱持点走;空着的时候什么都不做(交给物理 / 同步) +func _physics_process(_delta: float) -> void: + if carrier_peer == 0: + return + var socket := _find_carrier_socket() + if socket != null: + apply_hold_transform(socket) -## 放下 / 扔出:恢复物理并继承一个初速度 -func end_carry(velocity: Vector3) -> void: - carried_by = null - if freeze_while_carried: - freeze = false - collision_layer = _saved_collision_layer - linear_velocity = velocity - - -## 把自己摆到抱持点上。持有者每物理帧调一次 —— 偏移和朝向由物品自己决定,玩家不用管。 +## 把自己摆到抱持点上。偏移和朝向由物品自己决定,玩家不用管。 func apply_hold_transform(socket: Node3D) -> void: var socket_basis := socket.global_basis global_position = socket.global_position + socket_basis * hold_offset @@ -72,3 +79,40 @@ func apply_hold_transform(socket: Node3D) -> void: ## 按蓄力比例算出手速度(0 = 轻点,1 = 蓄满) func throw_speed(charge: float) -> float: return lerpf(throw_speed_min, throw_speed_max, clampf(charge, 0.0, 1.0)) + + +## 请求抱起。客户端用 rpc_id(1, ...) 发给服务器仲裁(先到先得),单机时服务器直接执行。 +## 服务器只改 `carrier_peer` 这一个值,其余交给同步 —— 各端看到的状态因此必然一致。 +@rpc("any_peer", "call_remote", "reliable") +func request_pickup(peer_id: int) -> void: + if not multiplayer.is_server() or carrier_peer != 0: + return + carrier_peer = peer_id + + +## 放下 / 扔出。持有者算好初速度发给服务器:服务器收回模拟权,让物品带着这个速度接着飞。 +@rpc("any_peer", "call_remote", "reliable") +func request_release(velocity: Vector3) -> void: + if not multiplayer.is_server() or carrier_peer == 0: + return + carrier_peer = 0 + linear_velocity = velocity + + +## 抱着时冻结物理、清掉碰撞层(不清会把抱着它的人顶飞); +## 空着时只有服务器模拟,其余端冻住跟着同步过来的位置走,免得各端各算一套。 +func _update_simulation() -> void: + if carrier_peer != 0: + freeze = true + collision_layer = 0 + return + collision_layer = _saved_collision_layer + freeze = not multiplayer.is_server() + + +## 找持有者的抱持点。找不到就什么都不做 —— 远端玩家还没生成时会出现这种情况。 +func _find_carrier_socket() -> Node3D: + for node in get_tree().get_nodes_in_group(PLAYER_GROUP): + if str(node.name) == str(carrier_peer): + return node.get_node_or_null(SOCKET_PATH) as Node3D + return null diff --git a/world/items/carryable_box.tscn b/world/items/carryable_box.tscn index a954fc9..aa5ff1f 100644 --- a/world/items/carryable_box.tscn +++ b/world/items/carryable_box.tscn @@ -1,6 +1,7 @@ -[gd_scene load_steps=5 format=3] +[gd_scene load_steps=6 format=3] [ext_resource type="Script" path="res://world/items/carryable.gd" id="1_carryable"] +[ext_resource type="SceneReplicationConfig" path="res://world/items/carryable_sync.tres" id="2_sync"] [sub_resource type="BoxMesh" id="BoxMesh_body"] size = Vector3(0.6, 0.6, 0.6) @@ -22,3 +23,6 @@ surface_material_override/0 = SubResource("StandardMaterial3D_box") [node name="Collision" type="CollisionShape3D" parent="."] shape = SubResource("BoxShape3D_body") + +[node name="Sync" type="MultiplayerSynchronizer" parent="."] +replication_config = ExtResource("2_sync") diff --git a/world/items/carryable_sync.tres b/world/items/carryable_sync.tres new file mode 100644 index 0000000..d4b26a0 --- /dev/null +++ b/world/items/carryable_sync.tres @@ -0,0 +1,12 @@ +[gd_resource type="SceneReplicationConfig" format=3] + +[resource] +properties/0/path = NodePath(".:position") +properties/0/spawn = true +properties/0/replication_mode = 1 +properties/1/path = NodePath(".:rotation") +properties/1/spawn = true +properties/1/replication_mode = 1 +properties/2/path = NodePath(".:carrier_peer") +properties/2/spawn = true +properties/2/replication_mode = 1 diff --git a/world/zipline/zipline.gd b/world/zipline/zipline.gd index 6727300..5f6a0bb 100644 --- a/world/zipline/zipline.gd +++ b/world/zipline/zipline.gd @@ -86,22 +86,22 @@ func _process(_delta: float) -> void: # ---------------- 对外接口(玩家控制器只读这几个) ---------------- ## 缆上参数 t∈[0,1] 处的世界坐标。 +## 曲线先在**本地空间**算完再转世界:sag 是本地单位,父节点一有缩放整条缆要跟着一起缩, +## 形状才不走样(混用 global 锚点 + 未缩放的 sag,下垂比例会随父节点缩放而变)。 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)) + return to_global(_curve_local(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() + 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 sample(t) - Vector3.UP * hang_offset + return to_global(_curve_local(t) - Vector3.UP * hang_offset) ## 找出世界坐标 pos 在缆上最近的一点,供玩家「就近吸附」。 @@ -134,9 +134,9 @@ 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)) +## 下垂曲线在**本节点局部空间**的点。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 个顶点, @@ -146,17 +146,17 @@ func _rebuild_cable() -> void: return var st := SurfaceTool.new() st.begin(Mesh.PRIMITIVE_TRIANGLE_STRIP) - var prev := _local_sample(0.0) + var prev := _curve_local(0.0) length = 0.0 for i in range(segments + 1): var t := float(i) / float(segments) - var p := _local_sample(t) + var p := _curve_local(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 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)