193 lines
8.6 KiB
GDScript
193 lines
8.6 KiB
GDScript
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
|