拾起物品同步

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
+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