- Added PropTrajectory class for deterministic ballistic calculations. - Introduced StickmanPropInteraction component to manage prop interactions (pickup, drop, throw). - Implemented grip resolution and attachment logic for props. - Added signals for interaction events (prop_picked_up, prop_dropped, prop_thrown). - Defined specifications for prop interaction lifecycle and event rules. - Ensured compatibility with existing prop systems and added new capabilities for prop pickup.
259 lines
11 KiB
GDScript
259 lines
11 KiB
GDScript
class_name StickmanPropInteraction
|
|
extends Node
|
|
## Owns the one-prop hand interaction lifecycle for a StickmanRig.
|
|
|
|
const PROP_BLOCK := preload("res://scripts/prop_block.gd")
|
|
const PROP_ANCHOR_DATA := preload("res://scripts/prop_anchor_data.gd")
|
|
const PROP_TRAJECTORY := preload("res://scripts/prop_trajectory.gd")
|
|
|
|
const REACH_DURATION: float = 0.18
|
|
const ARM_REACH: float = 360.0
|
|
const RELEASE_SPIN: float = 1.5
|
|
enum State { IDLE, APPROACHING, REACHING, HOLDING, WINDUP }
|
|
|
|
signal interaction_finished(succeeded: bool)
|
|
signal prop_transition(action_type: StringName, rig: StickmanRig, prop: PropBlock, reason: StringName)
|
|
|
|
var _rig: StickmanRig = null
|
|
var _locomotion: StickmanLocomotion = null
|
|
var _state: State = State.IDLE
|
|
var _held_prop: PropBlock = null
|
|
var _reserved_prop: PropBlock = null
|
|
var _hand: StringName = &"right"
|
|
var _grip: Dictionary = {}
|
|
var _saved_hand_target: Vector2 = Vector2.ZERO
|
|
var _timer: float = 0.0
|
|
var _pending_action: Dictionary = {}
|
|
var _saved_physics: Dictionary = {}
|
|
var _last_rig_rotation: float = 0.0
|
|
var _angular_snapshot: float = 0.0
|
|
|
|
func configure(rig: StickmanRig, locomotion: StickmanLocomotion) -> void:
|
|
_rig = rig
|
|
_locomotion = locomotion
|
|
|
|
func has_held_prop() -> bool:
|
|
return _held_prop != null and is_instance_valid(_held_prop)
|
|
|
|
func get_held_prop() -> PropBlock:
|
|
return _held_prop if has_held_prop() else null
|
|
|
|
func begin_action(action: Dictionary) -> bool:
|
|
var type: String = String(action.get("type", ""))
|
|
match type:
|
|
"pick_up_prop":
|
|
return _begin_pickup(action)
|
|
"drop_prop":
|
|
if not has_held_prop():
|
|
return false
|
|
release_held(&"drop", _rig_velocity(), 0.0, &"prop_dropped")
|
|
interaction_finished.emit(true)
|
|
return true
|
|
"throw_prop":
|
|
return _begin_throw(action)
|
|
return false
|
|
|
|
func tick(delta: float) -> void:
|
|
if has_held_prop():
|
|
_update_attachment()
|
|
if _state == State.APPROACHING:
|
|
if _locomotion != null and _locomotion.consume_walk_completed():
|
|
_state = State.REACHING
|
|
_timer = REACH_DURATION
|
|
return
|
|
if _state != State.REACHING and _state != State.WINDUP:
|
|
return
|
|
_timer -= delta
|
|
if _state == State.REACHING:
|
|
_update_reach_target()
|
|
if _timer <= 0.0:
|
|
_complete_pickup()
|
|
elif _timer <= 0.0:
|
|
_complete_throw()
|
|
|
|
func update_held_attachment(delta: float) -> void:
|
|
if _rig != null and is_instance_valid(_rig) and delta > 0.0:
|
|
_angular_snapshot = wrapf(_rig.global_rotation - _last_rig_rotation, -PI, PI) / delta
|
|
_last_rig_rotation = _rig.global_rotation
|
|
if has_held_prop(): _update_attachment()
|
|
|
|
func cancel() -> void:
|
|
if _state == State.APPROACHING or _state == State.REACHING or _state == State.WINDUP:
|
|
_restore_hand_target()
|
|
if _reserved_prop != null and is_instance_valid(_reserved_prop):
|
|
_reserved_prop.held_by_instance_id = 0
|
|
_reserved_prop = null
|
|
_pending_action.clear()
|
|
_state = State.IDLE
|
|
|
|
func release_for_ragdoll() -> void:
|
|
if has_held_prop():
|
|
release_held(&"ragdoll", _rig_velocity(), _angular_snapshot if is_finite(_angular_snapshot) else 0.0, &"prop_dropped")
|
|
|
|
func release_for_lifecycle(reason: StringName = &"lifecycle") -> void:
|
|
if has_held_prop():
|
|
release_held(reason, _rig_velocity(), 0.0, &"prop_dropped")
|
|
else:
|
|
cancel()
|
|
|
|
func release_held(reason: StringName, linear_velocity: Vector2, angular_velocity: float, event_type: StringName) -> bool:
|
|
if not has_held_prop():
|
|
return false
|
|
var prop: PropBlock = _held_prop
|
|
_update_attachment()
|
|
prop.freeze = bool(_saved_physics.get("freeze", false))
|
|
prop.freeze_mode = int(_saved_physics.get("freeze_mode", RigidBody2D.FREEZE_MODE_STATIC))
|
|
prop.collision_layer = int(_saved_physics.get("collision_layer", prop.collision_layer))
|
|
prop.collision_mask = int(_saved_physics.get("collision_mask", prop.collision_mask))
|
|
prop.z_index = int(_saved_physics.get("z_index", prop.z_index))
|
|
prop.gravity_scale = float(_saved_physics.get("gravity_scale", prop.gravity_scale))
|
|
prop.linear_damp = float(_saved_physics.get("linear_damp", prop.linear_damp))
|
|
prop.angular_damp = float(_saved_physics.get("angular_damp", prop.angular_damp))
|
|
prop.linear_velocity = linear_velocity if linear_velocity.is_finite() else Vector2.ZERO
|
|
prop.angular_velocity = angular_velocity if is_finite(angular_velocity) else 0.0
|
|
prop.held_by_instance_id = 0
|
|
_held_prop = null
|
|
_reserved_prop = null
|
|
_saved_physics.clear()
|
|
_restore_hand_target()
|
|
_state = State.IDLE
|
|
prop_transition.emit(event_type, _rig, prop, reason)
|
|
return true
|
|
|
|
static func gravity_for(prop: PropBlock) -> Vector2:
|
|
var direction: Vector2 = ProjectSettings.get_setting("physics/2d/default_gravity_vector", Vector2.DOWN)
|
|
var magnitude: float = float(ProjectSettings.get_setting("physics/2d/default_gravity", 980.0))
|
|
return direction * magnitude * prop.gravity_scale
|
|
|
|
func _begin_pickup(action: Dictionary) -> bool:
|
|
if has_held_prop() or _state != State.IDLE:
|
|
return false
|
|
var prop: PropBlock = instance_from_id(int(action.get("target_prop", 0))) as PropBlock
|
|
if prop == null or not is_instance_valid(prop) or not prop.can_be_picked_up or prop.held_by_instance_id != 0:
|
|
return false
|
|
var choice: Dictionary = _choose_grip(prop, String(action.get("hand", "auto")))
|
|
if choice.is_empty():
|
|
return false
|
|
_reserved_prop = prop
|
|
_reserved_prop.held_by_instance_id = _rig.get_instance_id()
|
|
_hand = StringName(choice["hand"])
|
|
_grip = choice["grip"]
|
|
_saved_hand_target = _hand_marker().global_position
|
|
_last_rig_rotation = _rig.global_rotation
|
|
_pending_action = action.duplicate(true)
|
|
var grip_world: Vector2 = _grip_world_point(prop, _grip)
|
|
if _can_reach(grip_world):
|
|
_state = State.REACHING
|
|
_timer = REACH_DURATION
|
|
else:
|
|
var bounds: Rect2 = prop.get_visual_local_bounds()
|
|
var left: float = prop.to_global(Vector2(bounds.position.x, bounds.get_center().y)).x
|
|
var right: float = prop.to_global(Vector2(bounds.end.x, bounds.get_center().y)).x
|
|
var side: float = -1.0 if _rig.global_position.x <= grip_world.x else 1.0
|
|
var feet: Vector2 = _rig.global_position - StickmanLocomotion.FOOT_OFFSET
|
|
var stand_point: Vector2 = Vector2(left - ARM_REACH * 0.65 if side < 0.0 else right + ARM_REACH * 0.65, feet.y)
|
|
if _locomotion == null or not _locomotion.walk_to(stand_point):
|
|
cancel()
|
|
return false
|
|
_state = State.APPROACHING
|
|
return true
|
|
|
|
func _complete_pickup() -> void:
|
|
if _reserved_prop == null or not is_instance_valid(_reserved_prop) or not _can_reach(_grip_world_point(_reserved_prop, _grip)):
|
|
cancel()
|
|
interaction_finished.emit(false)
|
|
return
|
|
_held_prop = _reserved_prop
|
|
_saved_physics = {"freeze": _held_prop.freeze, "freeze_mode": _held_prop.freeze_mode, "collision_layer": _held_prop.collision_layer, "collision_mask": _held_prop.collision_mask, "z_index": _held_prop.z_index, "gravity_scale": _held_prop.gravity_scale, "linear_damp": _held_prop.linear_damp, "angular_damp": _held_prop.angular_damp}
|
|
_held_prop.freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC
|
|
_held_prop.freeze = true
|
|
_held_prop.collision_layer = 0
|
|
_held_prop.collision_mask = 0
|
|
_held_prop.linear_velocity = Vector2.ZERO
|
|
_held_prop.angular_velocity = 0.0
|
|
_held_prop.z_index += int(_grip.get("z_index_offset", 0))
|
|
_state = State.HOLDING
|
|
_update_attachment()
|
|
prop_transition.emit(&"prop_picked_up", _rig, _held_prop, &"pickup")
|
|
interaction_finished.emit(true)
|
|
|
|
func _begin_throw(action: Dictionary) -> bool:
|
|
if not has_held_prop() or _state != State.HOLDING:
|
|
return false
|
|
var requested: int = int(action.get("target_prop", _held_prop.get_instance_id()))
|
|
if requested != _held_prop.get_instance_id() or not action.get("target_position") is Vector2:
|
|
return false
|
|
var time: float = float(action.get("flight_time", 0.0))
|
|
var solved: Dictionary = PROP_TRAJECTORY.solve_throw_velocity(_hand_marker().global_position, action["target_position"] as Vector2, time, gravity_for(_held_prop))
|
|
if not bool(solved.get("ok", false)):
|
|
return false
|
|
_pending_action = action.duplicate(true)
|
|
_timer = 0.10
|
|
_state = State.WINDUP
|
|
return true
|
|
|
|
func _complete_throw() -> void:
|
|
if not has_held_prop() or not _pending_action.get("target_position") is Vector2:
|
|
cancel()
|
|
interaction_finished.emit(false)
|
|
return
|
|
var solved: Dictionary = PROP_TRAJECTORY.solve_throw_velocity(_hand_marker().global_position, _pending_action["target_position"] as Vector2, float(_pending_action.get("flight_time", 0.0)), gravity_for(_held_prop))
|
|
if not bool(solved.get("ok", false)):
|
|
_restore_hand_target(); _state = State.HOLDING; interaction_finished.emit(false); return
|
|
release_held(&"throw", solved["velocity"] as Vector2, RELEASE_SPIN, &"prop_thrown")
|
|
interaction_finished.emit(true)
|
|
|
|
func _choose_grip(prop: PropBlock, requested_hand: String) -> Dictionary:
|
|
var options: Array[Dictionary] = []
|
|
for hand_id: String in ["right", "left"]:
|
|
if requested_hand != "auto" and requested_hand != hand_id:
|
|
continue
|
|
var enum_hand: int = PROP_ANCHOR_DATA.Hand.RIGHT if hand_id == "right" else PROP_ANCHOR_DATA.Hand.LEFT
|
|
for id: String in PROP_ANCHOR_DATA.compatible_grip_ids(prop.anchors, enum_hand):
|
|
options.append({"hand": hand_id, "grip": prop.anchors[id], "id": id})
|
|
if options.is_empty():
|
|
var bounds: Rect2 = prop.get_visual_local_bounds()
|
|
var fallback: Dictionary = {"position": bounds.get_center(), "rotation_deg": 0.0, "z_index_offset": 0, "allowed_hands": "both"}
|
|
for fallback_hand: String in ["right", "left"]:
|
|
if requested_hand == "auto" or requested_hand == fallback_hand:
|
|
options.append({"hand": fallback_hand, "grip": fallback, "id": "fallback"})
|
|
options.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
|
|
var ad: float = _marker_for(String(a["hand"])).global_position.distance_squared_to(_grip_world_point(prop, a["grip"] as Dictionary))
|
|
var bd: float = _marker_for(String(b["hand"])).global_position.distance_squared_to(_grip_world_point(prop, b["grip"] as Dictionary))
|
|
if not is_equal_approx(ad, bd): return ad < bd
|
|
if String(a["hand"]) != String(b["hand"]): return String(a["hand"]) == "right"
|
|
return String(a["id"]) < String(b["id"])
|
|
)
|
|
return options[0]
|
|
|
|
func _update_reach_target() -> void:
|
|
if _reserved_prop == null or not is_instance_valid(_reserved_prop):
|
|
return
|
|
var goal: Vector2 = _reserved_prop.global_transform * (_grip.get("position", Vector2.ZERO) as Vector2)
|
|
_hand_marker().global_position = _saved_hand_target.lerp(goal, clampf(1.0 - _timer / REACH_DURATION, 0.0, 1.0))
|
|
|
|
func _update_attachment() -> void:
|
|
if not has_held_prop(): return
|
|
var wrist: Transform2D = _hand_marker().global_transform
|
|
_held_prop.global_transform = PROP_ANCHOR_DATA.solve_attachment_transform(wrist, _grip, _held_prop.scale, _rig.facing_profile == StickmanRig.FacingProfile.LEFT)
|
|
|
|
func _can_reach(point: Vector2) -> bool:
|
|
return _hand_marker().global_position.distance_to(point) <= ARM_REACH
|
|
|
|
func _grip_world_point(prop: PropBlock, grip: Dictionary) -> Vector2:
|
|
return prop.global_transform * (grip.get("position", Vector2.ZERO) as Vector2)
|
|
|
|
func _hand_marker() -> Marker2D:
|
|
return _marker_for(String(_hand))
|
|
|
|
func _marker_for(hand: String) -> Marker2D:
|
|
return _rig._get_ik_marker("Right_Hand" if hand == "right" else "Left_Hand")
|
|
|
|
func _restore_hand_target() -> void:
|
|
var marker: Marker2D = _hand_marker()
|
|
if marker != null and is_instance_valid(marker): marker.global_position = _saved_hand_target
|
|
|
|
func _rig_velocity() -> Vector2:
|
|
if _locomotion != null and _locomotion.terrain_air_velocity.is_finite(): return _locomotion.terrain_air_velocity
|
|
return Vector2.ZERO
|