Files

193 lines
8.6 KiB
GDScript
Raw Permalink 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.
class_name PlayerPickup
extends Node
## 拾取组件:**按住左键拖拽,松手放下,按 Q 朝准心方向甩出去**。
## 按下左键时用射线抓起正前方的 RigidBody3D,按住期间持续吸在 InteractionPos 前;
## 松开左键即原地放开,按 Q 则沿视线方向把手上的物体甩出去。
## 只管「抓 / 拖 / 放 / 甩」,不碰移动与视角。
##
## **能否被拖拽由 PickupItem 标记决定**(scripts/item.gd):
## 只有挂了该脚本、且 can_pickup 为 true 的刚体才抓得起来。判定见 can_grab()。
##
## 接法:Player 下挂一个普通 Node,脚本挂上去;由 Player._ready() 注入射线、持握点与玩家本体。
## 拖拽力度:物体被拉向持握点的速度系数。越大跟手越紧,但过大容易来回抖。
## 注意:它同时决定了拖拽时的稳态滞后 —— 滞后距离 = 移动速度 / pick_power。
@export var pick_power: float = 10.0
## 投掷初速度(米/秒):按 Q 甩出去时给物体的速度大小。
@export var throw_speed: float = 12.0
## 拖拽速度上限(米/秒)。防止物体离得远时被一股脑拽飞。
@export var max_drag_speed: float = 30.0
## 没挂 PickupItem 标记的刚体,是否也允许拖拽。
## false(默认)= 白名单:**只有挂了标记且 can_pickup 为 true 的才拖得动**。
## true = 黑名单:没挂标记的默认可拖,只有显式设成 false 的不能拖。
@export var allow_unmarked: bool = false
## 交互状态变化时发出。
## hovering = 准心正指着可拖拽的刚体(还没抓);holding = 正抓着东西。
## Player 接住这个信号去切准心外观。只在两者之一变化时发,避免每帧刷 UI。
signal interact_state_changed(hovering: bool, holding: bool)
var _current: RigidBody3D = null
var _ray: RayCast3D
var _hold_pos: Marker3D
## 玩家本体。用来给被拖物体加碰撞例外,以及做速度前馈。
var _body: CharacterBody3D
## 上一次发出的状态,用来只在「变化那一刻」发信号。
var _hovering: bool = false
var _holding: bool = false
## 由 Player 在 _ready() 注入依赖。body 是玩家本体(CharacterBody3D)。
func setup(ray: RayCast3D, hold_pos: Marker3D, body: CharacterBody3D) -> void:
_ray = ray
_hold_pos = hold_pos
_body = body
func _unhandled_input(event: InputEvent) -> void:
# 只有鼠标锁进游戏(真正在玩)时才响应抓 / 拖 / 放 / 甩。
# 否则「点一下把鼠标锁回来」的那次点击会顺带抓起一个物体。
if Input.mouse_mode != Input.MOUSE_MODE_CAPTURED:
return
# 长按拖拽:按下抓起,松开放下;拖拽途中按 Q 可以把手上的东西甩出去。
# 按住期间不需要任何输入处理 —— 物体靠 physics_step() 每物理帧持续吸住。
if event.is_action_pressed("carry"):
_grab()
elif event.is_action_released("carry"):
_drop()
elif event.is_action_pressed("throw_carry"):
_throw()
## 每物理帧由 Player 调用一次,排在 move_and_slide() 之后。
## 用速度把物体拽向持握点,而不是直接改 global_position ——
## 这样它仍会和场景里其他刚体正常碰撞,不会被硬塞进墙里。
func physics_step() -> void:
_update_interact_state()
# 用 is_instance_valid 而不是 `== null`:物体可能在拖拽途中被 destroy,
# 那种情况下引用不是 null 而是「已被释放」,直接取 global_position 会报错。
if not is_instance_valid(_current) or not is_instance_valid(_hold_pos):
return
var to_hold := _hold_pos.global_position - _current.global_position
var desired := to_hold * pick_power
# 速度前馈:把玩家自身的速度加进去。
#
# 只靠 to_hold * pick_power 的话,它是一个一阶跟随 —— 人匀速前进时物体必然
# 落后一个稳态距离,大小 = 移动速度 / pick_power。按 5 / 10 算就是 0.5 米,
# 比持握点离身体的 0.44 米还远,于是走着走着物体就退进了玩家碰撞体里,
# 被物理推出来、又被拽回去,两边打架 —— 看起来就是「一卡一卡」。
# 加上玩家速度后,匀速行走时滞后归零,物体稳稳待在身前。
if _body != null:
desired += _body.velocity
if desired.length() > max_drag_speed:
desired = desired.normalized() * max_drag_speed
_current.set_linear_velocity(desired)
## 这个碰撞体现在能不能被拖拽。
## 硬条件:必须是 RigidBody3D(只有刚体才谈得上被拖)。
## 软条件:它身上的 PickupItem 标记允许 —— 没挂标记时怎么办由 allow_unmarked 决定。
func can_grab(collider: Object) -> bool:
if not (collider is RigidBody3D):
return false
var marker := _find_item_marker(collider as Node)
if marker == null:
return allow_unmarked
return marker.can_pickup
## 找挂在物体上的 PickupItem 标记,顺序:刚体自身 → 它的直接子节点 → owner 链向上。
##
## **为什么必须向上找**:像 SM_Bottle_1.glb 这类资源,实例化后是这个结构 ——
## SM_Bottle_12 (Node3D) ← 脚本通常挂在这一层(实例的根)
## └── SM_Bottle_1 (RigidBody3D) ← 但射线打中的是这一层
## 而 glb 内部节点的 owner 恰好指向实例根,所以沿 owner 链一步就能找到。
##
## 向上时**只检查节点自身、不检查它们的子节点** —— 否则一旦往上走到 World 那一层,
## 就会去遍历几百个子节点,而这是每物理帧都要跑一次的。
func _find_item_marker(body: Node) -> PickupItem:
if body is PickupItem:
return body as PickupItem
for child in body.get_children():
if child is PickupItem:
return child as PickupItem
var container: Node = body.owner
while container != null:
if container is PickupItem:
return container as PickupItem
container = container.owner
return null
## 检测「指着可拖拽物 / 正抓着东西」这两个状态,**只在变化时**发信号。
## 每帧都发会让 UI 无谓重绘,所以这里自己记着上一次的结果。
func _update_interact_state() -> void:
if _ray == null:
return
# 主动刷新一次:RayCast3D 的结果通常滞后于本帧的 move_and_slide,
# 不强制更新的话,跑动中瞄到物体要慢一帧才亮。
_ray.force_raycast_update()
var hovering := can_grab(_ray.get_collider())
var holding := is_instance_valid(_current)
if hovering == _hovering and holding == _holding:
return
_hovering = hovering
_holding = holding
interact_state_changed.emit(_hovering, _holding)
func _grab() -> void:
if _ray == null or is_instance_valid(_current):
return
var collider := _ray.get_collider()
# 再判一次「能不能抓」,而不是直接信 hovering 状态 ——
# 悬停是上一物理帧算出来的,这中间 can_pickup 可能已经被改掉。
if not can_grab(collider):
return
_current = collider as RigidBody3D
# 让被拖的物体和玩家本体**互不碰撞**。
# 否则物体一旦挨到身体就会被物理推出,而拖拽又立刻把它拉回来 ——
# 两个解算器互相打架,表现就是持续的高频抖动。
# 只解除「物体 ↔ 玩家」这一对,物体和场景里其他东西照样正常碰撞。
if _body != null:
_current.add_collision_exception_with(_body)
func _drop() -> void:
# 故意不清零线速度:松手时保留拖动惯性,甩出去的手感更自然。
# 想要「就地放下、不再滑出去」的话,这里补一句:
# if is_instance_valid(_current): _current.set_linear_velocity(Vector3.ZERO)
_forget(_current)
_current = null
## 清掉抓取时留下的碰撞例外。物体可能已被销毁,所以先判有效性。
func _forget(body: RigidBody3D) -> void:
if is_instance_valid(body) and _body != null:
body.remove_collision_exception_with(_body)
## 按 Q 投掷:沿视线方向把物体甩出去,随后立刻松手。
func _throw() -> void:
if not is_instance_valid(_current) or not is_instance_valid(_hold_pos):
return
# 持握点挂在摄像机下、且只有平移没有旋转,所以它的 basis 就是视线朝向,
# 其 -Z 即「准心正前方」。这样不必再给组件注入一台摄像机。
var dir := -_hold_pos.global_transform.basis.z
_current.set_linear_velocity(dir * throw_speed)
# 想让手感更「物理」(重物飞得慢、轻物飞得远)可以换成给冲量:
# _current.apply_central_impulse(dir * throw_speed)
_forget(_current)
_current = null
## 当前是否抓着东西。调试用,也方便以后加「搬运时不能开枪」这类规则。
func has_object() -> bool:
return is_instance_valid(_current)
## 当前准心是否指着可拖拽的刚体(还没抓)。调试用。
func is_hovering() -> bool:
return _hovering