拾起物品同步
This commit is contained in:
@@ -123,6 +123,56 @@
|
|||||||
**未做**:联机同步(第 2 步)。现在物品位置只有持有者本地在写,远端玩家看不到箱子被抱走 —— 需要给物品加
|
**未做**:联机同步(第 2 步)。现在物品位置只有持有者本地在写,远端玩家看不到箱子被抱走 —— 需要给物品加
|
||||||
`MultiplayerSynchronizer` + 跟随持有者的 authority 切换 + 抢占仲裁。
|
`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 一起缩,等用户拍板。
|
||||||
|
|
||||||
## 给「抱起物品系统」出了方案(未动代码)
|
## 给「抱起物品系统」出了方案(未动代码)
|
||||||
|
|
||||||
用户要新功能:玩家抱起物品,先要方案。我在对话里给了设计,没有写文件。
|
用户要新功能:玩家抱起物品,先要方案。我在对话里给了设计,没有写文件。
|
||||||
|
|||||||
@@ -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` 直接挂在
|
- 玩家控制器只认「运动结果」,不认识任何动画片段名;表现层脚本 `player_animator.gd` 直接挂在
|
||||||
`Body/AnimationPlayer` 上(`animation_player` 留空即自绑自身,所以两者是同一个节点)。
|
`Body/AnimationPlayer` 上(`animation_player` 留空即自绑自身,所以两者是同一个节点)。
|
||||||
新增动作状态改 `ANIM_CLIPS`,不要往 `player.gd` 里塞片段名。
|
新增动作状态改 `ANIM_CLIPS`,不要往 `player.gd` 里塞片段名。
|
||||||
@@ -89,12 +100,19 @@
|
|||||||
**参数按「世界方向」语义**(`rest_basis.inverse() * world * rest_basis` 换算到骨骼本地空间)——
|
**参数按「世界方向」语义**(`rest_basis.inverse() * world * rest_basis` 换算到骨骼本地空间)——
|
||||||
直接写骨骼本地欧拉角会得到「手往上后方摆」这种莫名其妙的结果,换模型必错。
|
直接写骨骼本地欧拉角会得到「手往上后方摆」这种莫名其妙的结果,换模型必错。
|
||||||
默认 swing 40 / tuck 50 / roll 0 / elbow 15 / stretch 1.25 → 手落在模型本地 (±1.17, 0.08, 0.95)。
|
默认 swing 40 / tuck 50 / roll 0 / elbow 15 / stretch 1.25 → 手落在模型本地 (±1.17, 0.08, 0.95)。
|
||||||
- 本地玩家第一人称藏了自己的模型(`hide_own_body`),所以**自己看不到自己的胳膊**,晃动只有别的玩家能看到;
|
- 本地玩家第一人称只藏身体、**留下两条胳膊**(`_hide_body_keep_arms()` 按 `arm_` 前缀筛,找不到就整个藏掉)——
|
||||||
想自己看就把 `hide_own_body` 关掉并把 `eye_height` 降下来。
|
抱东西时得看得见自己的手。
|
||||||
- **未做**:联机同步。现在物品位置只有持有者本地在写,远端看不到箱子被抱走 ——
|
- **联机只同步 `carrier_peer` 一个整数**(物品自己带 `MultiplayerSynchronizer`,配置 `world/items/carryable_sync.tres`)。
|
||||||
要给物品加 `MultiplayerSynchronizer`、authority 随持有者切换、再加抢占仲裁。
|
**抱着的时候位置根本不走同步**:每一端都从「持有者的抱持点」把物品摆出来(`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、
|
- 测试 `tests/test_carry.gd`:`Carryable` / `CarryArms` 都标了 `@tool`,否则编辑器里是 placeholder、
|
||||||
方法调不动、`_ready` 不跑(组也加不上);改完脚本要 `filesystem_manage(op="scan")` 才重新解析。
|
方法调不动、`_ready` 不跑(组也加不上);改完脚本要 `filesystem_manage(op="scan")` 才重新解析。
|
||||||
|
玩家脚本不是 @tool,所以「第一人称留胳膊」那条测试查的是源码 + 模型里真有 `arm_*` mesh。
|
||||||
|
|
||||||
## Godot 4 踩坑
|
## Godot 4 踩坑
|
||||||
|
|
||||||
|
|||||||
+51
-15
@@ -39,8 +39,8 @@ const JUMP_BUFFER_FRAMES: int = 6
|
|||||||
@export_group("视角")
|
@export_group("视角")
|
||||||
## 眼睛高度(米)。相机就架在这个高度上 —— 按碰撞胶囊定,跟模型高矮无关。
|
## 眼睛高度(米)。相机就架在这个高度上 —— 按碰撞胶囊定,跟模型高矮无关。
|
||||||
@export var eye_height: float = 1.6
|
@export var eye_height: float = 1.6
|
||||||
## 第一人称下藏掉自己的模型。不藏的话相机要么在模型里、要么贴着头顶,画面糊一片。
|
## 第一人称下藏掉自己的身体。**两条胳膊会留下来** —— 抱东西时得看得见自己的手。
|
||||||
## 想做成「看得见自己身体」的第一人称,就把这个关掉、并把 eye_height 降到模型头部高度。
|
## 想连胳膊也看不见就把这个关掉(那样相机要么在模型里、要么贴着头顶,画面会糊一片)。
|
||||||
## 只影响本机这一份 —— 远端玩家的模型照常显示。
|
## 只影响本机这一份 —— 远端玩家的模型照常显示。
|
||||||
@export var hide_own_body: bool = true
|
@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_charge_time: float = 0.8
|
||||||
## 扔出去时额外叠上玩家自身的速度(跑着扔会飞更远)
|
## 扔出去时额外叠上玩家自身的速度(跑着扔会飞更远)
|
||||||
@export var throw_inherits_velocity: bool = true
|
@export var throw_inherits_velocity: bool = true
|
||||||
|
## 等服务器确认抱起的宽限时间(秒)。联机下被别人先抢走时,超时就自动松手
|
||||||
|
@export var pickup_confirm_time: float = 0.6
|
||||||
|
|
||||||
@onready var body: Node3D = $Body
|
@onready var body: Node3D = $Body
|
||||||
## 眼睛分两层转:CameraRig 绕竖直轴转(yaw),Camera 原地抬头低头(pitch)。
|
## 眼睛分两层转:CameraRig 绕竖直轴转(yaw),Camera 原地抬头低头(pitch)。
|
||||||
@@ -83,9 +85,9 @@ const JUMP_BUFFER_FRAMES: int = 6
|
|||||||
@onready var _health_label: Label3D = $HealthLabel
|
@onready var _health_label: Label3D = $HealthLabel
|
||||||
## 滑索操作提示,同样在 player.tscn 里配好,默认隐藏
|
## 滑索操作提示,同样在 player.tscn 里配好,默认隐藏
|
||||||
@onready var _hint: Label3D = $InteractHint
|
@onready var _hint: Label3D = $InteractHint
|
||||||
## 抱持点:抱起来的物品每物理帧摆到它上面
|
## 抱东西时的胳膊表现层(挂 carry_arms.gd):只收「抱没抱、走多快」。
|
||||||
@onready var _carry_socket: Node3D = $CameraRig/CarrySocket
|
## 抱持点(CameraRig/CarrySocket)不在这里引用 —— 物品自己按名字找持有者的抱持点,
|
||||||
## 抱东西时的胳膊表现层(挂 carry_arms.gd):只收「抱没抱、走多快」
|
## 这样每端算出来的位置都一样,联机不用同步物品位置。
|
||||||
@onready var _carry_arms: CarryArms = $Body/CarryArms
|
@onready var _carry_arms: CarryArms = $Body/CarryArms
|
||||||
|
|
||||||
## 动画状态机(挂在 Body/AnimationPlayer 上)。只把「运动结果」交给它,也只知道它会自己播动画。
|
## 动画状态机(挂在 Body/AnimationPlayer 上)。只把「运动结果」交给它,也只知道它会自己播动画。
|
||||||
@@ -110,12 +112,14 @@ var _zip_speed: float = 0.0
|
|||||||
## 挂上瞬间「人相对缆」的偏移,随后衰减到 0 —— 吸附是滑过去的,不是瞬移
|
## 挂上瞬间「人相对缆」的偏移,随后衰减到 0 —— 吸附是滑过去的,不是瞬移
|
||||||
var _zip_grab_offset: Vector3 = Vector3.ZERO
|
var _zip_grab_offset: Vector3 = Vector3.ZERO
|
||||||
|
|
||||||
## 手上抱着的物品。非 null 时每物理帧把它摆到抱持点上。
|
## 手上抱着的物品。位置由物品自己跟住抱持点(见 Carryable),玩家不插手 ——
|
||||||
## 只在权威端写;远端玩家的物品位置由物品自己的 Sync 复制过来。
|
## 这样每端算出来的位置都一样,联机不用同步物品位置。
|
||||||
var _carried: Carryable = null
|
var _carried: Carryable = null
|
||||||
## 长按 Q 的蓄力进度 0~1
|
## 长按 Q 的蓄力进度 0~1
|
||||||
var _throw_charge: float = 0.0
|
var _throw_charge: float = 0.0
|
||||||
var _throw_charging: bool = false
|
var _throw_charging: bool = false
|
||||||
|
## 等服务器确认抱起的剩余时间。服务器没给(被别人先抢了)就自己松手
|
||||||
|
var _pickup_wait: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
@@ -134,7 +138,7 @@ func _ready() -> void:
|
|||||||
return
|
return
|
||||||
camera_rig.position.y = eye_height
|
camera_rig.position.y = eye_height
|
||||||
if hide_own_body:
|
if hide_own_body:
|
||||||
body.visible = false
|
_hide_body_keep_arms()
|
||||||
_yaw = camera_rig.rotation.y
|
_yaw = camera_rig.rotation.y
|
||||||
_pitch = clampf(camera.rotation.x, deg_to_rad(pitch_min_deg), deg_to_rad(pitch_max_deg))
|
_pitch = clampf(camera.rotation.x, deg_to_rad(pitch_min_deg), deg_to_rad(pitch_max_deg))
|
||||||
_apply_look()
|
_apply_look()
|
||||||
@@ -143,6 +147,25 @@ func _ready() -> void:
|
|||||||
_refresh_health_label()
|
_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:
|
func _unhandled_input(event: InputEvent) -> void:
|
||||||
if event is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
|
if event is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
|
||||||
_yaw -= event.relative.x * mouse_sensitivity
|
_yaw -= event.relative.x * mouse_sensitivity
|
||||||
@@ -218,11 +241,15 @@ func _physics_process(delta: float) -> void:
|
|||||||
else:
|
else:
|
||||||
_hint.visible = false
|
_hint.visible = false
|
||||||
|
|
||||||
# 抱着东西:每帧把它摆到抱持点上,并推进 Q 的蓄力。
|
# 抱着东西:推进 Q 的蓄力。物品位置不归玩家管 —— 它自己跟住抱持点(见 Carryable),
|
||||||
# 物品的位置由持有者写 —— 远端玩家的那份由物品自己的 Sync 复制过去。
|
# 每端都这么算,所以联机不用同步物品位置。
|
||||||
if _carried != null:
|
if _carried != null:
|
||||||
_carried.apply_hold_transform(_carry_socket)
|
if _carried.carrier_peer != multiplayer.get_unique_id():
|
||||||
if _throw_charging:
|
# 服务器还没确认,或者被别人先抢走了:给一小段时间,超时就松手
|
||||||
|
_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))
|
_throw_charge = minf(1.0, _throw_charge + delta / maxf(throw_charge_time, 0.01))
|
||||||
# 胳膊表现层只收「抱没抱、走多快」,摆成什么样是它自己的事
|
# 胳膊表现层只收「抱没抱、走多快」,摆成什么样是它自己的事
|
||||||
_carry_arms.set_carrying(
|
_carry_arms.set_carrying(
|
||||||
@@ -310,15 +337,20 @@ func _find_carryable() -> Carryable:
|
|||||||
|
|
||||||
|
|
||||||
## 抱起最近的可抱物品。手上已经有东西、或者正挂在缆上都不抱。
|
## 抱起最近的可抱物品。手上已经有东西、或者正挂在缆上都不抱。
|
||||||
|
## 实际「谁抱到了」由服务器仲裁(先到先得),这里只是发起请求并乐观地先认领,
|
||||||
|
## 服务器不给就在 _physics_process 里超时松手。
|
||||||
func _pick_up() -> void:
|
func _pick_up() -> void:
|
||||||
if _carried != null or _zipline != null:
|
if _carried != null or _zipline != null:
|
||||||
return
|
return
|
||||||
var item := _find_carryable()
|
var item := _find_carryable()
|
||||||
if item == null:
|
if item == null:
|
||||||
return
|
return
|
||||||
item.begin_carry(self)
|
|
||||||
item.apply_hold_transform(_carry_socket)
|
|
||||||
_carried = item
|
_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:
|
if throw_inherits_velocity:
|
||||||
launch += velocity
|
launch += velocity
|
||||||
_throw_charge = 0.0
|
_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
@@ -3,12 +3,14 @@ extends McpTestSuite
|
|||||||
## 回归测试:抱起物品系统的契约。
|
## 回归测试:抱起物品系统的契约。
|
||||||
##
|
##
|
||||||
## 谁能被抱由**物品自己**决定(挂 carryable.gd + 进 carryable 组),玩家只管找/抱/扔;
|
## 谁能被抱由**物品自己**决定(挂 carryable.gd + 进 carryable 组),玩家只管找/抱/扔;
|
||||||
## 抱持偏移和投掷速度也写在物品上。这里盯住这几条契约,改物品或改玩家时先让测试红一次。
|
## 抱持偏移和投掷速度也写在物品上。联机只同步 `carrier_peer` 一个整数 ——
|
||||||
|
## 抱着的时候位置不走同步,每端都从持有者的抱持点算出来,这几条契约坏了联机就会「各看各的」。
|
||||||
|
|
||||||
const PLAYER_SCENE_PATH := "res://character/player/player.tscn"
|
const PLAYER_SCENE_PATH := "res://character/player/player.tscn"
|
||||||
const ITEM_SCENE_PATH := "res://world/items/carryable_box.tscn"
|
const ITEM_SCENE_PATH := "res://world/items/carryable_box.tscn"
|
||||||
const CARRY_ACTION := "carry"
|
const CARRY_ACTION := "carry"
|
||||||
const THROW_ACTION := "throw_carry"
|
const THROW_ACTION := "throw_carry"
|
||||||
|
const HOLDER_PEER := 7
|
||||||
|
|
||||||
|
|
||||||
func suite_name() -> String:
|
func suite_name() -> String:
|
||||||
@@ -33,6 +35,28 @@ func _into_tree(node: Node) -> Node:
|
|||||||
return 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:
|
func test_carry_actions_exist() -> void:
|
||||||
_ensure_input_map()
|
_ensure_input_map()
|
||||||
# 动作没了按键就完全没反应,而且不报错 —— 这种静默失效必须由测试兜住
|
# 动作没了按键就完全没反应,而且不报错 —— 这种静默失效必须由测试兜住
|
||||||
@@ -56,63 +80,111 @@ func test_player_scene_has_carry_nodes() -> void:
|
|||||||
"Body/CarryArms 没挂 carry_arms.gd,胳膊不会抱东西")
|
"Body/CarryArms 没挂 carry_arms.gd,胳膊不会抱东西")
|
||||||
|
|
||||||
|
|
||||||
func test_example_item_is_carryable() -> void:
|
## 第一人称要看得见自己的胳膊。
|
||||||
var packed := load(ITEM_SCENE_PATH) as PackedScene
|
## player.gd 不是 @tool,在编辑器里是 placeholder、方法调不动,所以这里查两件事:
|
||||||
assert_true(packed != null, "%s 未能加载 —— 可抱物品的示例没了" % ITEM_SCENE_PATH)
|
## 代码里确实按 `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:
|
if packed == null:
|
||||||
return
|
return
|
||||||
var item := packed.instantiate()
|
var player := packed.instantiate()
|
||||||
assert_true(item is Carryable, "示例物品根节点不是 Carryable(%s)" % item.get_class())
|
track(player)
|
||||||
if not item is Carryable:
|
var skeleton := player.get_node_or_null("Body/mouse/MouseRig/Skeleton3D")
|
||||||
track(item)
|
assert_true(skeleton != null, "找不到骨架,模型结构变了?")
|
||||||
|
if skeleton == null:
|
||||||
return
|
return
|
||||||
var carryable := item as Carryable
|
var arms := 0
|
||||||
_into_tree(carryable)
|
var others := 0
|
||||||
assert_true(carryable.is_in_group(Carryable.GROUP),
|
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)
|
"可抱物品没进 %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:
|
func test_carry_freezes_body_and_restores_on_release() -> void:
|
||||||
var item := _into_tree((load(ITEM_SCENE_PATH) as PackedScene).instantiate()) as Carryable
|
var item := _spawn_item()
|
||||||
assert_true(item != null, "示例物品实例化失败")
|
|
||||||
if item == null:
|
if item == null:
|
||||||
return
|
return
|
||||||
var layer := item.collision_layer
|
var layer := item.collision_layer
|
||||||
assert_true(item.can_be_carried(), "新物品应该是可以抱的")
|
assert_true(item.can_be_carried(), "新物品应该是可以抱的")
|
||||||
var holder := Node3D.new()
|
item.request_pickup(HOLDER_PEER)
|
||||||
track(holder)
|
assert_eq(item.carrier_peer, HOLDER_PEER, "抱起请求没记到 carrier_peer 上")
|
||||||
item.begin_carry(holder)
|
|
||||||
assert_true(not item.can_be_carried(), "抱起来之后不该还能被别人抱走")
|
assert_true(not item.can_be_carried(), "抱起来之后不该还能被别人抱走")
|
||||||
assert_true(item.freeze, "抱起来要冻结物理,否则会和抱着它的人打架")
|
assert_true(item.freeze, "抱起来要冻结物理,否则会和抱着它的人打架")
|
||||||
assert_eq(item.collision_layer, 0, "抱起来要清掉碰撞层,否则物品会把玩家顶飞")
|
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(item.can_be_carried(), "放下之后应该又能被抱")
|
||||||
assert_true(not item.freeze, "放下要恢复物理")
|
assert_true(not item.freeze, "单机 / 服务器端放下要恢复物理模拟")
|
||||||
assert_eq(item.collision_layer, layer, "放下要恢复原来的碰撞层")
|
assert_eq(item.collision_layer, layer, "放下要恢复原来的碰撞层")
|
||||||
assert_true(item.linear_velocity.is_equal_approx(Vector3(1.0, 2.0, 3.0)),
|
assert_true(item.linear_velocity.is_equal_approx(Vector3(1.0, 2.0, 3.0)),
|
||||||
"放下时要继承扔出去的速度,实际 %s" % item.linear_velocity)
|
"放下时要继承扔出去的速度,实际 %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
|
func test_carried_item_follows_holder_socket() -> void:
|
||||||
if item == null:
|
var item := _spawn_item()
|
||||||
|
var holder := _fake_holder()
|
||||||
|
if item == null or holder == null:
|
||||||
return
|
return
|
||||||
var socket := Marker3D.new()
|
item.request_pickup(HOLDER_PEER)
|
||||||
track(socket)
|
item._physics_process(0.016)
|
||||||
socket.position = Vector3(1.0, 2.0, -3.0)
|
var socket := holder.get_node("CameraRig/CarrySocket") as Node3D
|
||||||
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)
|
|
||||||
var expected := socket.global_position + socket.global_basis * item.hold_offset
|
var expected := socket.global_position + socket.global_basis * item.hold_offset
|
||||||
assert_true(item.global_position.distance_to(expected) < 0.001,
|
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:
|
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:
|
if item == null:
|
||||||
return
|
return
|
||||||
assert_true(absf(item.throw_speed(0.0) - item.throw_speed_min) < 0.001,
|
assert_true(absf(item.throw_speed(0.0) - item.throw_speed_min) < 0.001,
|
||||||
|
|||||||
+37
-1
@@ -19,6 +19,8 @@ const PLAYER_SCENE_PATH := "res://character/player/player.tscn"
|
|||||||
const INTERACT_ACTION := "interact"
|
const INTERACT_ACTION := "interact"
|
||||||
## 挂在编辑中场景下。前缀 _McpTest 是测试运行器兜底清理认的标记
|
## 挂在编辑中场景下。前缀 _McpTest 是测试运行器兜底清理认的标记
|
||||||
const TEST_NODE_NAME := "_McpTestZipline"
|
const TEST_NODE_NAME := "_McpTestZipline"
|
||||||
|
## 测试自己造的父节点:不带任何变换,免得编辑中场景的缩放漏进来
|
||||||
|
const TEST_PARENT_NAME := "_McpTestZiplineHolder"
|
||||||
|
|
||||||
var _zip: Zipline = null
|
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:
|
func setup() -> void:
|
||||||
_zip = _instantiate_zipline()
|
_zip = _instantiate_zipline()
|
||||||
if _zip != null:
|
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:
|
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)
|
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 必须是真实欧氏距离。
|
## 玩家挂索靠这个接口。返回的点必须在曲线上,distance 必须是真实欧氏距离。
|
||||||
func test_closest_point_lands_on_curve() -> void:
|
func test_closest_point_lands_on_curve() -> void:
|
||||||
var probe := _zip.sample(0.3) + Vector3(1.0, 1.0, 0.0)
|
var probe := _zip.sample(0.3) + Vector3(1.0, 1.0, 0.0)
|
||||||
@@ -186,3 +212,13 @@ func _instantiate_zipline() -> Zipline:
|
|||||||
return null
|
return null
|
||||||
zip.name = TEST_NODE_NAME
|
zip.name = TEST_NODE_NAME
|
||||||
return zip
|
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
@@ -7,22 +7,30 @@ extends RigidBody3D
|
|||||||
## 和 Zipline 一样,手感参数放物品这一侧:不同物品可以有不同的抱持偏移、不同的投掷速度,
|
## 和 Zipline 一样,手感参数放物品这一侧:不同物品可以有不同的抱持偏移、不同的投掷速度,
|
||||||
## 玩家只负责「找到它、把它摆到手上、扔出去」,不定义手感。
|
## 玩家只负责「找到它、把它摆到手上、扔出去」,不定义手感。
|
||||||
##
|
##
|
||||||
## 用法:把这个脚本挂在物品根节点(RigidBody3D)上即可,别的什么都不用配。
|
## 用法:把这个脚本挂在物品根节点(RigidBody3D)上,再给它加一个 MultiplayerSynchronizer
|
||||||
|
## (配置见 res://world/items/carryable_sync.tres,同步 position / rotation / carrier_peer)。
|
||||||
## 参考示例见 res://world/items/carryable_box.tscn。
|
## 参考示例见 res://world/items/carryable_box.tscn。
|
||||||
##
|
##
|
||||||
|
## 联机只同步一个整数:`carrier_peer`(谁抱着)。
|
||||||
|
## **抱着的时候位置根本不走同步** —— 每一端都从「持有者的抱持点」把物品摆出来,
|
||||||
|
## 结果天然一致,也省掉了「持有者写位置、同步又把位置写回来」的互相打架。
|
||||||
|
## 空着的时候才靠 position / rotation 同步,而且只有服务器在模拟。
|
||||||
|
##
|
||||||
## 标了 @tool 是为了能在编辑器里跑测试(非 @tool 脚本在编辑器里是 placeholder,方法调不动);
|
## 标了 @tool 是为了能在编辑器里跑测试(非 @tool 脚本在编辑器里是 placeholder,方法调不动);
|
||||||
## 运行时行为完全一样。
|
## 运行时行为完全一样。
|
||||||
|
|
||||||
## 所有可抱物品都进这个组,玩家靠它找物品
|
## 所有可抱物品都进这个组,玩家靠它找物品
|
||||||
const GROUP: StringName = &"carryable"
|
const GROUP: StringName = &"carryable"
|
||||||
|
## 玩家节点在世界里以 peer id 命名并进了这个组,靠它找持有者的抱持点
|
||||||
|
const PLAYER_GROUP: StringName = &"net_player"
|
||||||
|
## 抱持点在玩家场景里的相对路径
|
||||||
|
const SOCKET_PATH: String = "CameraRig/CarrySocket"
|
||||||
|
|
||||||
@export_group("抱持")
|
@export_group("抱持")
|
||||||
## 被抱时相对抱持点的偏移(米)。物品大小不同,靠它对准手心
|
## 被抱时相对抱持点的偏移(米)。物品大小不同,靠它对准手心
|
||||||
@export var hold_offset: Vector3 = Vector3.ZERO
|
@export var hold_offset: Vector3 = Vector3.ZERO
|
||||||
## 被抱时的朝向(度)
|
## 被抱时的朝向(度)
|
||||||
@export var hold_rotation_deg: Vector3 = Vector3.ZERO
|
@export var hold_rotation_deg: Vector3 = Vector3.ZERO
|
||||||
## 抱起来时冻结物理、并清掉碰撞层 —— 不清的话物品会把抱着它的人顶飞
|
|
||||||
@export var freeze_while_carried: bool = true
|
|
||||||
|
|
||||||
@export_group("投掷")
|
@export_group("投掷")
|
||||||
## 长按蓄满力后的出手速度(米/秒)
|
## 长按蓄满力后的出手速度(米/秒)
|
||||||
@@ -30,8 +38,14 @@ const GROUP: StringName = &"carryable"
|
|||||||
## 轻点时最低出手速度(米/秒)
|
## 轻点时最低出手速度(米/秒)
|
||||||
@export var throw_speed_min: float = 5.0
|
@export var throw_speed_min: float = 5.0
|
||||||
|
|
||||||
## 现在被谁抱着。空 = 没人抱
|
## 谁抱着它(peer id),0 = 没人抱。
|
||||||
var carried_by: Node3D = null
|
## 由服务器改,通过物品自己的 MultiplayerSynchronizer 复制到各端;各端只读。
|
||||||
|
var carrier_peer: int = 0:
|
||||||
|
set(value):
|
||||||
|
if carrier_peer == value:
|
||||||
|
return
|
||||||
|
carrier_peer = value
|
||||||
|
_update_simulation()
|
||||||
|
|
||||||
var _saved_collision_layer: int = 1
|
var _saved_collision_layer: int = 1
|
||||||
|
|
||||||
@@ -39,30 +53,23 @@ var _saved_collision_layer: int = 1
|
|||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
add_to_group(GROUP)
|
add_to_group(GROUP)
|
||||||
_saved_collision_layer = collision_layer
|
_saved_collision_layer = collision_layer
|
||||||
|
_update_simulation()
|
||||||
|
|
||||||
|
|
||||||
func can_be_carried() -> bool:
|
func can_be_carried() -> bool:
|
||||||
return carried_by == null
|
return carrier_peer == 0
|
||||||
|
|
||||||
|
|
||||||
## 被抱起来:冻结物理、退出碰撞,之后由持有者每帧摆位置
|
## 抱着的时候跟着持有者的抱持点走;空着的时候什么都不做(交给物理 / 同步)
|
||||||
func begin_carry(holder: Node3D) -> void:
|
func _physics_process(_delta: float) -> void:
|
||||||
carried_by = holder
|
if carrier_peer == 0:
|
||||||
if freeze_while_carried:
|
return
|
||||||
freeze = true
|
var socket := _find_carrier_socket()
|
||||||
collision_layer = 0
|
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:
|
func apply_hold_transform(socket: Node3D) -> void:
|
||||||
var socket_basis := socket.global_basis
|
var socket_basis := socket.global_basis
|
||||||
global_position = socket.global_position + socket_basis * hold_offset
|
global_position = socket.global_position + socket_basis * hold_offset
|
||||||
@@ -72,3 +79,40 @@ func apply_hold_transform(socket: Node3D) -> void:
|
|||||||
## 按蓄力比例算出手速度(0 = 轻点,1 = 蓄满)
|
## 按蓄力比例算出手速度(0 = 轻点,1 = 蓄满)
|
||||||
func throw_speed(charge: float) -> float:
|
func throw_speed(charge: float) -> float:
|
||||||
return lerpf(throw_speed_min, throw_speed_max, clampf(charge, 0.0, 1.0))
|
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
|
||||||
|
|||||||
@@ -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="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"]
|
[sub_resource type="BoxMesh" id="BoxMesh_body"]
|
||||||
size = Vector3(0.6, 0.6, 0.6)
|
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="."]
|
[node name="Collision" type="CollisionShape3D" parent="."]
|
||||||
shape = SubResource("BoxShape3D_body")
|
shape = SubResource("BoxShape3D_body")
|
||||||
|
|
||||||
|
[node name="Sync" type="MultiplayerSynchronizer" parent="."]
|
||||||
|
replication_config = ExtResource("2_sync")
|
||||||
|
|||||||
@@ -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
@@ -86,22 +86,22 @@ func _process(_delta: float) -> void:
|
|||||||
# ---------------- 对外接口(玩家控制器只读这几个) ----------------
|
# ---------------- 对外接口(玩家控制器只读这几个) ----------------
|
||||||
|
|
||||||
## 缆上参数 t∈[0,1] 处的世界坐标。
|
## 缆上参数 t∈[0,1] 处的世界坐标。
|
||||||
|
## 曲线先在**本地空间**算完再转世界:sag 是本地单位,父节点一有缩放整条缆要跟着一起缩,
|
||||||
|
## 形状才不走样(混用 global 锚点 + 未缩放的 sag,下垂比例会随父节点缩放而变)。
|
||||||
func sample(t: float) -> Vector3:
|
func sample(t: float) -> Vector3:
|
||||||
var a := anchor_a.global_position
|
return to_global(_curve_local(t))
|
||||||
var b := anchor_b.global_position
|
|
||||||
return a.lerp(b, t) - Vector3.UP * (sag * 4.0 * t * (1.0 - t))
|
|
||||||
|
|
||||||
|
|
||||||
## 缆上参数 t 处的世界方向(单位向量,指向 B)。
|
## 缆上参数 t 处的世界方向(单位向量,指向 B)。
|
||||||
## 用解析导数而不是前后差分:差分在 t=0/1 处要越界取样,端点会偏。
|
## 用解析导数而不是前后差分:差分在 t=0/1 处要越界取样,端点会偏。
|
||||||
func tangent(t: float) -> Vector3:
|
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))
|
var d := (anchor_b.position - anchor_a.position) - Vector3.UP * (sag * 4.0 * (1.0 - 2.0 * t))
|
||||||
return d.normalized()
|
return (global_transform.basis * d).normalized()
|
||||||
|
|
||||||
|
|
||||||
## 乘坐时人应该待的世界坐标:缆点再往下挂 hang_offset。
|
## 乘坐时人应该待的世界坐标:缆点再往下挂 hang_offset。
|
||||||
func ride_point(t: float) -> Vector3:
|
func ride_point(t: float) -> Vector3:
|
||||||
return sample(t) - Vector3.UP * hang_offset
|
return to_global(_curve_local(t) - Vector3.UP * hang_offset)
|
||||||
|
|
||||||
|
|
||||||
## 找出世界坐标 pos 在缆上最近的一点,供玩家「就近吸附」。
|
## 找出世界坐标 pos 在缆上最近的一点,供玩家「就近吸附」。
|
||||||
@@ -134,9 +134,9 @@ func _downhill_dir() -> int:
|
|||||||
return 1 if anchor_b.global_position.y <= anchor_a.global_position.y else -1
|
return 1 if anchor_b.global_position.y <= anchor_a.global_position.y else -1
|
||||||
|
|
||||||
|
|
||||||
## 世界曲线转成本地坐标:缆绳网格是本节点的子节点,顶点必须落在局部空间里。
|
## 下垂曲线在**本节点局部空间**的点。sag 是本地单位,所以父节点缩放时整条缆一起缩、形状不变。
|
||||||
func _local_sample(t: float) -> Vector3:
|
func _curve_local(t: float) -> Vector3:
|
||||||
return to_local(sample(t))
|
return anchor_a.position.lerp(anchor_b.position, t) - Vector3.UP * (sag * 4.0 * t * (1.0 - t))
|
||||||
|
|
||||||
|
|
||||||
## 把下垂曲线扫成一根管子。用三角形带:每个截面 RING_SEGMENTS+1 个顶点,
|
## 把下垂曲线扫成一根管子。用三角形带:每个截面 RING_SEGMENTS+1 个顶点,
|
||||||
@@ -146,17 +146,17 @@ func _rebuild_cable() -> void:
|
|||||||
return
|
return
|
||||||
var st := SurfaceTool.new()
|
var st := SurfaceTool.new()
|
||||||
st.begin(Mesh.PRIMITIVE_TRIANGLE_STRIP)
|
st.begin(Mesh.PRIMITIVE_TRIANGLE_STRIP)
|
||||||
var prev := _local_sample(0.0)
|
var prev := _curve_local(0.0)
|
||||||
length = 0.0
|
length = 0.0
|
||||||
for i in range(segments + 1):
|
for i in range(segments + 1):
|
||||||
var t := float(i) / float(segments)
|
var t := float(i) / float(segments)
|
||||||
var p := _local_sample(t)
|
var p := _curve_local(t)
|
||||||
if i > 0:
|
if i > 0:
|
||||||
length += p.distance_to(prev)
|
length += p.distance_to(prev)
|
||||||
prev = p
|
prev = p
|
||||||
# 截面参考轴取「切线 × 世界上方向」。切线接近竖直时叉乘会退化成零向量,
|
# 截面参考轴取「切线 × 世界上方向」。切线接近竖直时叉乘会退化成零向量,
|
||||||
# 这时换 RIGHT 兜底,否则 normalize() 会吐 NaN 把整根缆搅烂。
|
# 这时换 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)
|
var n := tan_dir.cross(Vector3.UP)
|
||||||
if n.length_squared() < 0.0001:
|
if n.length_squared() < 0.0001:
|
||||||
n = tan_dir.cross(Vector3.RIGHT)
|
n = tan_dir.cross(Vector3.RIGHT)
|
||||||
|
|||||||
Reference in New Issue
Block a user