Files
agame/tests/test_carry.gd
T
2026-10-03 18:48:04 +08:00

225 lines
9.5 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@tool
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:
return "carry"
## 编辑器进程里的 InputMap 只有内建动作,项目动作要手动 load 一次才在
func _ensure_input_map() -> void:
InputMap.load_from_project_settings()
## 把节点塞进场景树,让 _ready() 真的跑起来;track() 负责回收
func _into_tree(node: Node) -> Node:
var editor := Engine.get_singleton("EditorInterface")
var root: Node = null
if editor != null:
root = editor.get_edited_scene_root()
if root == null:
root = Engine.get_main_loop().root
root.add_child(node)
track(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()
# 动作没了按键就完全没反应,而且不报错 —— 这种静默失效必须由测试兜住
assert_true(InputMap.has_action(CARRY_ACTION), "缺少 %s 动作,左键抱不起来" % CARRY_ACTION)
assert_true(InputMap.has_action(THROW_ACTION), "缺少 %s 动作,长按 Q 扔不出去" % THROW_ACTION)
func test_player_scene_has_carry_nodes() -> void:
var packed := load(PLAYER_SCENE_PATH) as PackedScene
assert_true(packed != null, "%s 未能加载" % PLAYER_SCENE_PATH)
if packed == null:
return
var player := packed.instantiate()
track(player)
var socket := player.get_node_or_null("CameraRig/CarrySocket")
assert_true(socket is Marker3D, "CameraRig 下缺少 CarrySocket(Marker3D)—— 物品没有抱持点")
var arms := player.get_node_or_null("Body/CarryArms")
assert_true(arms != null, "Body 下缺少 CarryArms 节点")
if arms != null:
assert_true(arms.get_script() != null and str(arms.get_script().resource_path).ends_with("carry_arms.gd"),
"Body/CarryArms 没挂 carry_arms.gd,胳膊不会抱东西")
## 第一人称要看得见自己的胳膊。
## 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 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 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(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 := _spawn_item()
if item == null:
return
var layer := item.collision_layer
assert_true(item.can_be_carried(), "新物品应该是可以抱的")
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.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_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_carried_item_follows_holder_socket() -> void:
var item := _spawn_item()
var holder := _fake_holder()
if item == null or holder == null:
return
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])
# 松开之后就不该再跟了
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 := _spawn_item()
if item == null:
return
assert_true(absf(item.throw_speed(0.0) - item.throw_speed_min) < 0.001,
"蓄力为 0 时应该是最低出手速度")
assert_true(absf(item.throw_speed(1.0) - item.throw_speed_max) < 0.001,
"蓄满时应该是最高出手速度")
assert_true(item.throw_speed(0.5) > item.throw_speed_min and item.throw_speed(0.5) < item.throw_speed_max,
"出手速度应该随蓄力单调变化")
assert_true(item.throw_speed(9.0) <= item.throw_speed_max, "蓄力超出范围要被夹住")
## 抱起姿势必须真的把胳膊伸到身体**前方**(模型正面是本地 +Z,尾巴在 -Z 那一侧)。
## 参数写反或者骨骼名写错时,这里会红 —— 光看代码看不出胳膊朝哪边伸。
func test_carry_pose_reaches_forward() -> void:
var packed := load(PLAYER_SCENE_PATH) as PackedScene
if packed == null:
return
var player := _into_tree(packed.instantiate())
var arms := player.get_node_or_null("Body/CarryArms") as CarryArms
var skeleton := player.get_node_or_null("Body/mouse/MouseRig/Skeleton3D") as Skeleton3D
assert_true(arms != null and skeleton != null, "玩家场景里缺 CarryArms 或骨架")
if arms == null or skeleton == null:
return
var hand := skeleton.find_bone("arm_l_03")
assert_true(hand >= 0, "骨架里没有 arm_l_03,胳膊姿势会不完整")
if hand < 0:
return
var before := skeleton.get_bone_global_pose(hand).origin
arms.set_carrying(true, 0.0)
# 直接推进两次让它混合到位(测试里没有引擎帧)
arms._process(1.0)
arms._process(1.0)
var after := skeleton.get_bone_global_pose(hand).origin
assert_true(after.z > before.z + 0.5,
"抱起来时手要伸到身体前方:抱之前 z=%.2f,抱之后 z=%.2f" % [before.z, after.z])
assert_true(after.y > before.y,
"抱起来时手要抬到身前的高度:抱之前 y=%.2f,抱之后 y=%.2f" % [before.y, after.y])