Files
stickman/scripts/stickman_prop_interaction.gd
T
ryan 043f114083 Implement interactive prop handling for StickmanRig
- 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.
2026-09-23 21:26:13 -04:00

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