拾起物品同步

This commit is contained in:
2026-10-03 18:48:04 +08:00
parent 0dcfb88bfe
commit 3d23804fa5
10 changed files with 359 additions and 86 deletions
+50
View File
@@ -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 一起缩,等用户拍板。
## 给「抱起物品系统」出了方案(未动代码)
用户要新功能:玩家抱起物品,先要方案。我在对话里给了设计,没有写文件。
+22 -4
View File
@@ -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 踩坑
+1
View File
@@ -0,0 +1 @@
游戏内测试交给用户来,不要自己做
+51 -15
View File
@@ -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)
# ---------------- 滑索 ----------------
+103 -31
View File
@@ -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,
+37 -1
View File
@@ -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
+66 -22
View File
@@ -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
+5 -1
View File
@@ -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")
+12
View File
@@ -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
+12 -12
View File
@@ -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)