Files
stickman/scripts/stickman_prop_interactor.gd
T
ryan 6a1359f244 Add prop interaction system and throw mechanics specification
- Introduced `stickman_prop_interactor.gd` to manage prop interactions.
- Created `phase_11_prop_interactions.md` detailing the design and implementation of prop pickup, drop, and throw mechanics.
- Defined interaction states, action lifecycles, and event triggers for prop handling.
- Specified constraints for throw actions, including target positioning and velocity calculations.
- Established acceptance criteria for prop interactions and lifecycle management.
2026-09-25 23:42:26 -04:00

1105 lines
46 KiB
GDScript

class_name StickmanPropInteractor
extends Node
## Owns one-stickman/one-hand prop interaction state during Sandbox PLAY.
const ANCHOR_DATA := preload("res://scripts/prop_anchor_data.gd")
enum Hand { LEFT, RIGHT }
enum Phase { IDLE, APPROACHING, REACHING, PICKUP_RECOVERING, HELD, THROWING }
const MAX_REACH: float = 390.0
const APPROACH_GAP: float = 90.0
const APPROACH_REPLAN_LIMIT: int = 1
const REACH_DURATION: float = 0.34
const PICKUP_RECOVER_DURATION: float = 0.32
const LOW_PICKUP_THRESHOLD: float = 110.0
const LOW_PICKUP_MAX_DROP: float = 110.0
const LOW_PICKUP_LEAN: float = 28.0
const THROW_LIFT_DURATION: float = 0.30
const THROW_SWING_DURATION: float = 0.16
const THROW_FOLLOW_THROUGH: float = 0.24
const THROW_PREVIEW_HORIZON: float = 2.0
const MIN_THROW_FLIGHT_TIME: float = 0.35
const MAX_THROW_RANGE: float = 3600.0
const MAX_THROW_SPEED: float = 2100.0
const ZERO_THROW_DISTANCE: float = 4.0
const THROW_DESCENT_EPSILON: float = 0.001
const THROW_SPIN_SPEED: float = 2.4
const THROW_TRAJECTORY_SEGMENTS: int = 24
const THROW_HEAD_CLEARANCE: float = 36.0
const THROW_REACH_MARGIN: float = 8.0
signal action_completed
signal prop_event(event_type: String, prop: PropBlock, reason: String)
var _rig: Node2D
var _locomotion: Node
var _resolver: Callable
var _phase: Phase = Phase.IDLE
var _held_prop: PropBlock
var _held_anchor: Dictionary = {}
var _hand: Hand = Hand.RIGHT
var _active_action: Dictionary = {}
var _reach_start: Vector2 = Vector2.ZERO
var _reach_target: Vector2 = Vector2.ZERO
var _reach_elapsed: float = 0.0
var _pickup_recover_elapsed: float = 0.0
var _pickup_torso_start: Vector2 = Vector2.ZERO
var _pickup_torso_start_rotation: float = 0.0
var _pickup_torso_crouched: Vector2 = Vector2.ZERO
var _pickup_torso_crouched_rotation: float = 0.0
var _pickup_uses_crouch: bool = false
var _throw_elapsed: float = 0.0
var _throw_released: bool = false
var _throw_base_marker_local: Vector2 = Vector2.ZERO
var _throw_windup_marker_local: Vector2 = Vector2.ZERO
var _throw_overhead_marker_local: Vector2 = Vector2.ZERO
var _throw_swing_control_marker_local: Vector2 = Vector2.ZERO
var _throw_release_marker_local: Vector2 = Vector2.ZERO
var _throw_follow_control_marker_local: Vector2 = Vector2.ZERO
var _throw_follow_marker_local: Vector2 = Vector2.ZERO
var _throw_release_attachment_local: Transform2D = Transform2D.IDENTITY
var _throw_facing_left: bool = false
var _throw_effective_target: Vector2 = Vector2.ZERO
var _throw_torso_start: Vector2 = Vector2.ZERO
var _throw_torso_windup: Vector2 = Vector2.ZERO
var _throw_torso_start_rotation: float = 0.0
var _throw_torso_windup_rotation: float = 0.0
var _approach_replans: int = 0
var _marker_start_position: Vector2 = Vector2.ZERO
var _saved_body_state: Dictionary = {}
var _last_transform: Transform2D = Transform2D.IDENTITY
var _sampled_linear_velocity: Vector2 = Vector2.ZERO
var _sampled_angular_velocity: float = 0.0
func _exit_tree() -> void:
if _held_prop != null and is_instance_valid(_held_prop):
_release_held("scene_reset", false)
else:
_clear_reservation()
func configure(rig: Node2D, locomotion: Node, resolver: Callable) -> void:
_rig = rig
_locomotion = locomotion
_resolver = resolver
## Returns true when the queue runner must wait for action_completed.
## Immediate success and safe no-op failures both return false.
func start_action(action: Dictionary) -> bool:
var type := String(action.get("type", ""))
match type:
"pick_up_prop":
return _begin_pickup(String(action.get("target_prop_id", "")), String(action.get("hand", "auto")), action)
"drop_prop":
if _held_prop != null and is_instance_valid(_held_prop):
_release_held("manual", false)
return false
"throw_prop":
if _held_prop == null or not is_instance_valid(_held_prop):
return false
var target: Variant = action.get("target_position", Vector2.INF)
if not target is Vector2 or not (target as Vector2).is_finite():
push_warning("StickmanPropInteractor: invalid throw target; skipped.")
return false
var raw_target := target as Vector2
if _rig == null or raw_target.distance_to(_rig.global_position) <= ZERO_THROW_DISTANCE:
return false
var effective_target := _clamp_throw_target(raw_target)
if absf(effective_target.x - _rig.global_position.x) > 0.5:
_rig.call("set_facing_profile", 0 if effective_target.x < _rig.global_position.x else 1)
var marker := _hand_marker()
if marker == null:
return false
var pose := _build_throw_pose(effective_target, marker, _held_prop, _hand, Vector2.ZERO)
if pose.is_empty():
return false
var throw_facing_left := bool(pose["facing_left"])
var pose_transform := _rig_transform_for_facing(throw_facing_left)
var release_marker_world: Vector2 = pose_transform * (pose["release"] as Vector2)
var release_attachment := _predicted_attachment_transform(release_marker_world, throw_facing_left, _held_prop, _held_anchor, _hand)
var release_attachment_local: Transform2D = pose_transform.affine_inverse() * release_attachment
var spin_sign := -1.0 if throw_facing_left else 1.0
var predicted_release := pose_transform * release_attachment_local
if _solve_throw(predicted_release, effective_target, _held_prop, spin_sign * THROW_SPIN_SPEED).is_empty():
return false
_store_throw_pose(pose, marker)
_throw_facing_left = throw_facing_left
_throw_release_attachment_local = release_attachment_local
_throw_effective_target = effective_target
_active_action = action.duplicate(true)
_throw_elapsed = 0.0
_throw_released = false
_phase = Phase.THROWING
return true
return false
func tick(delta: float) -> void:
if _rig == null or not is_instance_valid(_rig):
return
match _phase:
Phase.APPROACHING:
if _locomotion != null and bool(_locomotion.call("consume_walk_completed")):
_on_approach_arrived()
Phase.REACHING:
_update_reach(delta)
Phase.PICKUP_RECOVERING:
_update_pickup_recovery(delta)
Phase.HELD:
_update_held_transform(delta)
Phase.THROWING:
_update_throw(delta)
func cancel_action() -> void:
if _phase == Phase.APPROACHING and _locomotion != null:
_locomotion.call("cancel_walk")
if _phase == Phase.APPROACHING or _phase == Phase.REACHING:
_clear_reservation()
_restore_hand_target()
_restore_pickup_torso()
elif _phase == Phase.PICKUP_RECOVERING:
_restore_pickup_torso()
_phase = Phase.HELD
_active_action.clear()
return
elif _phase == Phase.THROWING and not _throw_released:
var marker := _hand_marker()
if marker != null:
marker.position = _throw_base_marker_local
_restore_throw_torso()
_phase = Phase.HELD if _held_prop != null else Phase.IDLE
_active_action.clear()
return
elif _phase == Phase.THROWING and _throw_released:
_throw_released = false
_throw_elapsed = 0.0
_restore_hand_target()
_restore_throw_torso()
if _phase != Phase.HELD:
_phase = Phase.IDLE
_active_action.clear()
func preview_throw(target: Vector2, queue_prefix: Array[Dictionary] = []) -> Dictionary:
if not target.is_finite():
return {}
var context := _build_throw_preview_context(queue_prefix)
if not bool(context.get("available", false)):
return {
"preview_available": false,
"preview_reason": String(context.get("reason", "No prop is held or planned before this throw.")),
}
var prop: PropBlock = context["prop"]
var anchor: Dictionary = context["anchor"]
var hand_value := int(context["hand"])
var root_offset: Vector2 = context["root_offset"]
var effective_target := _clamp_throw_target(target, context["root_position"])
if _rig == null or target.distance_to(context["root_position"]) <= ZERO_THROW_DISTANCE:
var invalid := _invalid_throw_preview(effective_target, _rig.global_position if _rig != null else target)
invalid.merge(_throw_preview_metadata(context), true)
return invalid
var marker := _hand_marker_for(hand_value)
if marker == null:
return {"preview_available": false, "preview_reason": "The planned pickup hand is unavailable."}
var pose := _build_throw_pose(effective_target, marker, prop, hand_value, root_offset)
if pose.is_empty():
return {"preview_available": false, "preview_reason": "The throw pose cannot be estimated from the current rig."}
var facing_left := bool(pose["facing_left"])
var release_marker_world := _rig_transform_for_facing(facing_left, root_offset) * (pose["release"] as Vector2)
var release_transform := _predicted_attachment_transform(release_marker_world, facing_left, prop, anchor, hand_value, root_offset)
var spin_speed := -THROW_SPIN_SPEED if facing_left else THROW_SPIN_SPEED
var solution := _solve_throw(release_transform, effective_target, prop, spin_speed)
if solution.is_empty():
var invalid := _invalid_throw_preview(effective_target, release_transform.origin)
invalid.merge(_throw_preview_metadata(context), true)
return invalid
solution.merge(_throw_preview_metadata(context), true)
solution["effective_target"] = effective_target
solution["target_reachable"] = true
return _clip_preview_to_collision(solution, prop)
func _build_throw_preview_context(queue_prefix: Array[Dictionary]) -> Dictionary:
if _rig == null or not is_instance_valid(_rig):
return {"available": false, "reason": "The acting stickman is unavailable."}
var prop: PropBlock = _held_prop if _held_prop != null and is_instance_valid(_held_prop) else null
var anchor: Dictionary = _held_anchor.duplicate(true) if prop != null else {}
var hand_value := int(_hand)
var has_prop := prop != null
var root_position := _rig.global_position
var root_offset := Vector2.ZERO
var predicted_facing_left := _is_facing_left()
var planned := false
var uncertain := _phase != Phase.IDLE and _phase != Phase.HELD
var plan_step := 0
for entry: Dictionary in queue_prefix:
var action: Dictionary = entry.get("action", {})
if action.is_empty():
continue
if bool(entry.get("active", false)):
uncertain = true
var action_type := String(action.get("type", ""))
match action_type:
"walk_to":
var feet_target: Variant = action.get("target", Vector2.INF)
if feet_target is Vector2 and (feet_target as Vector2).is_finite() and _locomotion != null:
var previous_root := root_position
var foot_offset: Vector2 = _locomotion.call("get_foot_offset")
root_position = (feet_target as Vector2) + foot_offset
root_offset = root_position - _rig.global_position
if not is_zero_approx(root_position.x - previous_root.x):
predicted_facing_left = root_position.x < previous_root.x
uncertain = true
"pick_up_prop":
if has_prop:
continue
var prop_id := String(action.get("target_prop_id", ""))
if prop_id.is_empty() or not _resolver.is_valid():
return {"available": false, "reason": "A pickup before this throw has no resolvable prop."}
var resolved: Variant = _resolver.call(prop_id)
if not resolved is PropBlock or not is_instance_valid(resolved) or (resolved as PropBlock).is_queued_for_deletion():
return {"available": false, "reason": "A pickup before this throw refers to a missing prop."}
var candidate_prop := resolved as PropBlock
var owner_id := int(candidate_prop.interaction_owner_id)
var reserved_by := int(candidate_prop.interaction_reserved_by)
if (owner_id != 0 and owner_id != int(_rig.get_instance_id())) or (reserved_by != 0 and reserved_by != int(_rig.get_instance_id())):
return {"available": false, "reason": "The planned pickup prop is held or reserved by another stickman."}
var requested_hand := String(action.get("hand", "auto"))
var picked := _choose_preview_grip_and_hand(candidate_prop, requested_hand, root_offset, predicted_facing_left)
if picked.is_empty():
return {"available": false, "reason": "The planned prop has no compatible grip for this pickup."}
var candidate_anchor: Dictionary = picked["anchor"]
var candidate_hand := int(picked["hand"])
var grip_position := _grip_world_position_for_facing(candidate_prop, candidate_anchor, predicted_facing_left)
var wrist_position := _preview_wrist_position(candidate_hand, root_offset, predicted_facing_left)
if wrist_position.distance_to(grip_position) > MAX_REACH:
uncertain = true
var side := -1.0 if root_position.x <= grip_position.x else 1.0
var approach_root := Vector2(grip_position.x + side * APPROACH_GAP, root_position.y)
if not is_equal_approx(approach_root.x, root_position.x):
predicted_facing_left = approach_root.x < root_position.x
root_position = approach_root
root_offset = root_position - _rig.global_position
grip_position = _grip_world_position_for_facing(candidate_prop, candidate_anchor, predicted_facing_left)
wrist_position = _preview_wrist_position(candidate_hand, root_offset, predicted_facing_left)
if wrist_position.distance_to(grip_position) > MAX_REACH:
uncertain = true
prop = candidate_prop
anchor = candidate_anchor.duplicate(true)
hand_value = candidate_hand
has_prop = true
planned = true
plan_step = int(entry.get("step", 0))
"drop_prop", "throw_prop":
if has_prop:
prop = null
anchor.clear()
has_prop = false
planned = false
uncertain = true
"ragdoll":
uncertain = true
if not has_prop or prop == null or not is_instance_valid(prop):
return {"available": false, "reason": "No prop is held or picked up earlier in this queue."}
return {
"available": true,
"prop": prop,
"anchor": anchor,
"hand": hand_value,
"root_position": root_position,
"root_offset": root_offset,
"planned": planned,
"uncertain": uncertain,
"plan_step": plan_step,
}
func _choose_preview_grip_and_hand(prop: PropBlock, requested_hand: String, root_offset: Vector2, facing_left: bool) -> Dictionary:
var anchors := prop.get_interaction_anchors()
var candidates: Array[Dictionary] = []
for hand_name: String in ["left", "right"]:
if requested_hand != "auto" and requested_hand != hand_name:
continue
var hand_value: Hand = Hand.LEFT if hand_name == "left" else Hand.RIGHT
var compatible: Array[String] = ANCHOR_DATA.compatible_grip_ids(anchors, hand_value)
for anchor_id: String in compatible:
var candidate_anchor: Dictionary = anchors.get(anchor_id, {})
var wrist_position := _preview_wrist_position(hand_value, root_offset, facing_left)
var grip_position := _grip_world_position_for_facing(prop, candidate_anchor, facing_left)
candidates.append({"anchor_id": anchor_id, "anchor": candidate_anchor, "hand": hand_value, "distance": wrist_position.distance_to(grip_position)})
if candidates.is_empty():
return {}
candidates.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
var a_primary := String(a.get("anchor_id", "")) == "primary_grip"
var b_primary := String(b.get("anchor_id", "")) == "primary_grip"
if a_primary != b_primary:
return a_primary
return float(a.get("distance", INF)) < float(b.get("distance", INF))
)
return candidates[0]
func _preview_wrist_position(hand_value: int, root_offset: Vector2, facing_left: bool) -> Vector2:
var wrist := _solved_wrist_transform_for(hand_value)
var rig_transform := _rig_transform_for_facing(facing_left, root_offset)
return rig_transform * _rig.to_local(wrist.origin)
func _throw_preview_metadata(context: Dictionary) -> Dictionary:
var planned := bool(context.get("planned", false))
var uncertain := bool(context.get("uncertain", false))
var hint := ""
var step := int(context.get("plan_step", 0))
if planned and step > 0:
hint = "Assumes Step %d pickup succeeds." % step
elif planned:
hint = "Assumes an earlier pickup succeeds."
if uncertain:
hint = "Estimated preview. " + hint if not hint.is_empty() else "Estimated preview from earlier actions."
return {"preview_available": true, "preview_planned": planned, "preview_uncertain": uncertain, "preview_hint": hint}
func _build_throw_pose(target: Vector2, marker: Marker2D, prop: PropBlock, hand_value: int, root_offset: Vector2) -> Dictionary:
var prethrow_delta := target - prop.global_position
if not prethrow_delta.is_finite():
return {}
var root_position := _rig.global_position + root_offset
var facing_left := target.x < root_position.x if absf(target.x - root_position.x) > 0.5 else _is_facing_left()
var suffix := "Left" if hand_value == Hand.LEFT else "Right"
var shoulder_bone := _rig.get_node_or_null(NodePath("Skeleton2D/Torso/%sUpperArm" % suffix)) as Bone2D
var lower_bone := _rig.get_node_or_null(NodePath("Skeleton2D/Torso/%sUpperArm/%sLowerArm" % [suffix, suffix])) as Bone2D
var head_node := _rig.get_node_or_null(NodePath("Body/Head")) as Node2D
if shoulder_bone == null or lower_bone == null or head_node == null:
return {}
var pose_transform := _rig_transform_for_facing(facing_left, root_offset)
var head_center := pose_transform * _rig.to_local(head_node.global_position)
var shoulder := pose_transform * _rig.to_local(shoulder_bone.global_position)
var head_radius := 100.0
var radius_value: Variant = head_node.get("radius")
if radius_value is float or radius_value is int:
head_radius = maxf(float(radius_value), 20.0)
var aim_sign := signf(target.x - root_position.x)
if is_zero_approx(aim_sign):
aim_sign = -1.0 if facing_left else 1.0
var max_reach := maxf(1.0, shoulder_bone.length + lower_bone.length - THROW_REACH_MARGIN)
var behind_control := head_center + Vector2(-aim_sign * head_radius * 0.78, -head_radius * 0.20)
var overhead := head_center + Vector2(-aim_sign * 8.0, -head_radius - THROW_HEAD_CLEARANCE)
var swing_control := head_center + Vector2(aim_sign * (head_radius * 0.62), -head_radius - THROW_HEAD_CLEARANCE * 0.45)
var release := head_center + Vector2(aim_sign * (head_radius + 44.0), -head_radius * 0.26)
var follow_control := release + Vector2(aim_sign * 44.0, -12.0)
var follow := release + Vector2(aim_sign * 88.0, 46.0)
behind_control = _clamp_to_arm_reach(behind_control, shoulder, max_reach)
overhead = _clamp_to_arm_reach(overhead, shoulder, max_reach)
swing_control = _clamp_to_arm_reach(swing_control, shoulder, max_reach)
release = _clamp_to_arm_reach(release, shoulder, max_reach)
follow_control = _clamp_to_arm_reach(follow_control, shoulder, max_reach)
follow = _clamp_to_arm_reach(follow, shoulder, max_reach)
var torso_marker := _torso_marker()
var torso_start := torso_marker.position if torso_marker != null else Vector2.ZERO
var torso_start_rotation := torso_marker.rotation if torso_marker != null else 0.0
var local_aim_sign := -aim_sign if facing_left else aim_sign
return {
"facing_left": facing_left,
"base": marker.position,
"windup": pose_transform.affine_inverse() * behind_control,
"overhead": pose_transform.affine_inverse() * overhead,
"swing_control": pose_transform.affine_inverse() * swing_control,
"release": pose_transform.affine_inverse() * release,
"follow_control": pose_transform.affine_inverse() * follow_control,
"follow": pose_transform.affine_inverse() * follow,
"torso_start": torso_start,
"torso_start_rotation": torso_start_rotation,
"torso_windup": torso_start + Vector2(-local_aim_sign * 24.0, -14.0),
"torso_windup_rotation": torso_start_rotation - local_aim_sign * 0.12,
}
func _store_throw_pose(pose: Dictionary, marker: Marker2D) -> void:
_throw_base_marker_local = marker.position
_throw_windup_marker_local = pose["windup"]
_throw_overhead_marker_local = pose["overhead"]
_throw_swing_control_marker_local = pose["swing_control"]
_throw_release_marker_local = pose["release"]
_throw_follow_control_marker_local = pose["follow_control"]
_throw_follow_marker_local = pose["follow"]
_throw_torso_start = pose["torso_start"]
_throw_torso_start_rotation = float(pose["torso_start_rotation"])
_throw_torso_windup = pose["torso_windup"]
_throw_torso_windup_rotation = float(pose["torso_windup_rotation"])
func _rig_transform_for_facing(facing_left: bool, root_offset: Vector2 = Vector2.ZERO) -> Transform2D:
var result := _rig.global_transform
result.origin += root_offset
var x_axis := result.x
if _is_facing_left():
x_axis = -x_axis
if facing_left:
x_axis = -x_axis
result.x = x_axis
return result
func _clamp_to_arm_reach(point: Vector2, shoulder: Vector2, max_reach: float) -> Vector2:
var delta := point - shoulder
if delta.length() <= max_reach or delta.is_zero_approx():
return point
return shoulder + delta.normalized() * max_reach
func _clamp_throw_target(target: Vector2, root_position: Vector2 = Vector2.INF) -> Vector2:
if _rig == null:
return target
var center := root_position if root_position.is_finite() else _rig.global_position
var offset := target - center
if offset.length() <= MAX_THROW_RANGE or offset.is_zero_approx():
return target
return center + offset.normalized() * MAX_THROW_RANGE
func _invalid_throw_preview(effective_target: Vector2, start_position: Vector2) -> Dictionary:
return {
"start_position": start_position,
"effective_target": effective_target,
"landing_position": effective_target,
"collision_position": Vector2.INF,
"collision_before_target": false,
"target_reachable": false,
"flight_time": 0.0,
"trajectory_points": PackedVector2Array(),
}
func disarm_for_ragdoll() -> void:
var interrupted_pickup := _phase == Phase.APPROACHING or _phase == Phase.REACHING or _phase == Phase.PICKUP_RECOVERING
var interrupted_windup := _phase == Phase.THROWING and not _throw_released
if _phase == Phase.APPROACHING or _phase == Phase.REACHING or _phase == Phase.PICKUP_RECOVERING:
cancel_action()
elif interrupted_windup:
cancel_action()
if _held_prop != null and is_instance_valid(_held_prop):
_release_held("ragdoll", false)
if interrupted_pickup or interrupted_windup:
action_completed.emit()
func reset_for_scene_exit() -> void:
if _phase == Phase.APPROACHING or _phase == Phase.REACHING or _phase == Phase.PICKUP_RECOVERING or (_phase == Phase.THROWING and not _throw_released):
cancel_action()
if _held_prop != null and is_instance_valid(_held_prop):
_release_held("scene_reset", false)
_phase = Phase.IDLE
_active_action.clear()
_restore_hand_target()
func _begin_pickup(prop_id: String, requested_hand: String, action: Dictionary) -> bool:
if _phase != Phase.IDLE or _held_prop != null or _rig == null:
return false
if int(_rig.get("state")) != 0:
return false
if prop_id.is_empty() or not _resolver.is_valid():
return false
var resolved: Variant = _resolver.call(prop_id)
if not resolved is PropBlock:
return false
var prop := resolved as PropBlock
if not is_instance_valid(prop) or prop.is_queued_for_deletion():
return false
if prop.interaction_owner_id != 0 or prop.interaction_reserved_by != 0:
return false
var picked := _choose_grip_and_hand(prop, requested_hand)
if picked.is_empty():
push_warning("StickmanPropInteractor: no compatible one-hand grip; pickup skipped.")
return false
_held_anchor = (picked["anchor"] as Dictionary).duplicate(true)
_hand = int(picked["hand"])
_held_prop = prop
_held_prop.interaction_reserved_by = _rig.get_instance_id()
_active_action = action.duplicate(true)
_approach_replans = 0
var marker := _hand_marker()
if marker == null:
_clear_reservation()
_phase = Phase.IDLE
return false
_marker_start_position = marker.position
var grip_position := _grip_world_position(prop, _held_anchor)
if _solved_wrist_transform().origin.distance_to(grip_position) > MAX_REACH:
if not _start_approach(grip_position):
_clear_reservation()
_phase = Phase.IDLE
return false
_phase = Phase.APPROACHING
return true
_start_reach(grip_position)
return true
func _choose_grip_and_hand(prop: PropBlock, requested_hand: String) -> Dictionary:
var anchors := prop.get_interaction_anchors()
var candidates: Array[Dictionary] = []
for hand_name: String in ["left", "right"]:
if requested_hand != "auto" and requested_hand != hand_name:
continue
var hand_value: int = Hand.LEFT if hand_name == "left" else Hand.RIGHT
var compatible: Array[String] = ANCHOR_DATA.compatible_grip_ids(anchors, hand_value)
for anchor_id: String in compatible:
var anchor: Dictionary = anchors.get(anchor_id, {})
var distance: float = _solved_wrist_transform_for(hand_value).origin.distance_to(_grip_world_position(prop, anchor))
candidates.append({"anchor_id": anchor_id, "anchor": anchor, "hand": hand_value, "distance": distance})
if candidates.is_empty():
return {}
candidates.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
var a_primary := String(a.get("anchor_id", "")) == "primary_grip"
var b_primary := String(b.get("anchor_id", "")) == "primary_grip"
if a_primary != b_primary:
return a_primary
return float(a.get("distance", INF)) < float(b.get("distance", INF))
)
return candidates[0]
func _start_approach(grip_position: Vector2) -> bool:
if _locomotion == null:
return false
var side := -1.0 if _rig.global_position.x <= grip_position.x else 1.0
var foot_offset: Vector2 = _locomotion.call("get_foot_offset")
var target_root := Vector2(grip_position.x + side * APPROACH_GAP, _rig.global_position.y)
var target_feet := target_root - foot_offset
return bool(_locomotion.call("walk_to", target_feet, -1.0))
func _on_approach_arrived() -> void:
if _held_prop == null or not is_instance_valid(_held_prop):
_clear_reservation()
_restore_hand_target()
_restore_pickup_torso()
_phase = Phase.IDLE
_active_action.clear()
action_completed.emit()
return
var grip_position := _grip_world_position(_held_prop, _held_anchor)
var marker := _hand_marker()
if marker == null:
_clear_reservation()
_restore_hand_target()
_phase = Phase.IDLE
_active_action.clear()
action_completed.emit()
return
if _solved_wrist_transform().origin.distance_to(grip_position) > MAX_REACH:
if _approach_replans < APPROACH_REPLAN_LIMIT:
_approach_replans += 1
if _start_approach(grip_position):
return
_clear_reservation()
_restore_hand_target()
_phase = Phase.IDLE
_active_action.clear()
action_completed.emit()
return
_start_reach(grip_position)
func _start_reach(grip_position: Vector2) -> void:
var marker := _hand_marker()
if marker == null:
_clear_reservation()
_restore_hand_target()
_phase = Phase.IDLE
_active_action.clear()
action_completed.emit()
return
_reach_start = marker.global_position
_reach_target = grip_position
_reach_elapsed = 0.0
var torso_marker := _torso_marker()
_pickup_uses_crouch = false
if torso_marker != null:
_pickup_torso_start = torso_marker.position
_pickup_torso_start_rotation = torso_marker.rotation
_pickup_torso_crouched = _pickup_torso_start
_pickup_torso_crouched_rotation = _pickup_torso_start_rotation
var grip_local := _rig.to_local(grip_position)
var low_amount := clampf((grip_local.y - _pickup_torso_start.y - LOW_PICKUP_THRESHOLD) / LOW_PICKUP_MAX_DROP, 0.0, 1.0)
if low_amount > 0.0:
var toward_prop := signf(grip_local.x - _pickup_torso_start.x)
if is_zero_approx(toward_prop):
toward_prop = -1.0 if _is_facing_left() else 1.0
_pickup_torso_crouched += Vector2(toward_prop * LOW_PICKUP_LEAN * low_amount, LOW_PICKUP_MAX_DROP * low_amount)
_pickup_torso_crouched_rotation -= toward_prop * 0.10 * low_amount
_pickup_uses_crouch = true
_rig.call("_set_limb_mods_enabled", true)
_phase = Phase.REACHING
func _update_reach(delta: float) -> void:
if _held_prop == null or not is_instance_valid(_held_prop):
_clear_reservation()
_restore_hand_target()
_phase = Phase.IDLE
action_completed.emit()
return
var marker := _hand_marker()
if marker == null:
cancel_action()
action_completed.emit()
return
_reach_elapsed += delta
var weight := clampf(_reach_elapsed / REACH_DURATION, 0.0, 1.0)
var eased_weight := smoothstep(0.0, 1.0, weight)
_reach_target = _grip_world_position(_held_prop, _held_anchor)
marker.global_position = _reach_start.lerp(_reach_target, eased_weight)
_update_pickup_crouch(eased_weight)
if weight < 1.0:
return
if _solved_wrist_transform().origin.distance_to(_reach_target) > MAX_REACH:
cancel_action()
action_completed.emit()
return
_commit_pickup(marker)
func _commit_pickup(marker: Marker2D) -> void:
if _held_prop == null or not is_instance_valid(_held_prop):
cancel_action()
action_completed.emit()
return
_saved_body_state = {
"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,
"z_as_relative": _held_prop.z_as_relative,
"linear_velocity": _held_prop.linear_velocity,
"angular_velocity": _held_prop.angular_velocity,
}
_held_prop.interaction_reserved_by = 0
_held_prop.interaction_owner_id = _rig.get_instance_id()
_held_prop.freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC
_held_prop.freeze = true
_held_prop.sleeping = false
_held_prop.collision_layer = 0
_held_prop.collision_mask = 0
if _pickup_uses_crouch:
_pickup_recover_elapsed = 0.0
_phase = Phase.PICKUP_RECOVERING
else:
_phase = Phase.HELD
_active_action.clear()
_update_held_transform(0.0)
prop_event.emit("prop_picked_up", _held_prop, "")
if not _pickup_uses_crouch:
action_completed.emit()
func _update_pickup_recovery(delta: float) -> void:
_pickup_recover_elapsed += delta
var weight := smoothstep(0.0, 1.0, clampf(_pickup_recover_elapsed / PICKUP_RECOVER_DURATION, 0.0, 1.0))
_update_pickup_crouch(1.0 - weight)
_update_held_transform(delta)
if weight < 1.0:
return
_restore_pickup_torso()
_phase = Phase.HELD
_active_action.clear()
action_completed.emit()
func _update_pickup_crouch(weight: float) -> void:
var torso_marker := _torso_marker()
if torso_marker == null or not _pickup_uses_crouch:
return
torso_marker.position = _pickup_torso_start.lerp(_pickup_torso_crouched, weight)
torso_marker.rotation = lerpf(_pickup_torso_start_rotation, _pickup_torso_crouched_rotation, weight)
func _restore_pickup_torso() -> void:
var torso_marker := _torso_marker()
if torso_marker != null and _pickup_uses_crouch:
torso_marker.position = _pickup_torso_start
torso_marker.rotation = _pickup_torso_start_rotation
_pickup_uses_crouch = false
func _update_held_transform(delta: float, follow_hand_marker: bool = false) -> void:
if _held_prop == null or not is_instance_valid(_held_prop):
_clear_held_state()
return
var marker := _hand_marker()
if follow_hand_marker and marker != null:
_held_prop.global_transform = _predicted_attachment_transform(marker.global_position, _is_facing_left())
else:
var wrist := _solved_wrist_transform()
var anchor := _scaled_anchor(_held_anchor, _held_prop)
_held_prop.global_transform = ANCHOR_DATA.solve_attachment_transform(wrist, anchor, Vector2.ONE, _is_facing_left())
_held_prop.z_as_relative = true
_held_prop.z_index = _rig.z_index + int(_held_anchor.get("z_index_offset", 0))
if delta > 0.0:
var current := _held_prop.global_transform
_sampled_linear_velocity = (current.origin - _last_transform.origin) / delta
_sampled_angular_velocity = wrapf(current.get_rotation() - _last_transform.get_rotation(), -PI, PI) / delta
_last_transform = _held_prop.global_transform
func _update_throw(delta: float) -> void:
_throw_elapsed += delta
var marker := _hand_marker()
var release_time := THROW_LIFT_DURATION + THROW_SWING_DURATION
if not _throw_released:
if _held_prop == null or not is_instance_valid(_held_prop):
_phase = Phase.IDLE
_active_action.clear()
action_completed.emit()
return
if marker != null:
if _throw_elapsed < THROW_LIFT_DURATION:
var lift_weight := smoothstep(0.0, 1.0, clampf(_throw_elapsed / THROW_LIFT_DURATION, 0.0, 1.0))
marker.position = _quadratic_bezier(_throw_base_marker_local, _throw_windup_marker_local, _throw_overhead_marker_local, lift_weight)
_update_throw_torso(lift_weight)
else:
var swing_weight := smoothstep(0.0, 1.0, clampf((_throw_elapsed - THROW_LIFT_DURATION) / THROW_SWING_DURATION, 0.0, 1.0))
marker.position = _quadratic_bezier(_throw_overhead_marker_local, _throw_swing_control_marker_local, _throw_release_marker_local, swing_weight)
_update_throw_torso(1.0 - swing_weight * 0.65)
_update_held_transform(delta, true)
if not _throw_released and _throw_elapsed >= release_time:
if marker != null:
marker.position = _throw_release_marker_local
_update_throw_torso(0.35)
if _held_prop != null and is_instance_valid(_held_prop):
_held_prop.global_transform = _rig_transform_for_facing(_throw_facing_left) * _throw_release_attachment_local
var target := _throw_effective_target
var spin_sign := -1.0 if _throw_facing_left else 1.0
var solution := _solve_throw(_held_prop.global_transform, target, _held_prop, spin_sign * THROW_SPIN_SPEED)
if solution.is_empty():
if marker != null:
marker.position = _throw_base_marker_local
_restore_throw_torso()
_phase = Phase.HELD
_active_action.clear()
action_completed.emit()
return
_release_held("throw", true, solution["velocity"], spin_sign * THROW_SPIN_SPEED)
_throw_released = true
if _throw_released and marker != null:
var follow_weight := smoothstep(0.0, 1.0, clampf((_throw_elapsed - release_time) / THROW_FOLLOW_THROUGH, 0.0, 1.0))
marker.position = _quadratic_bezier(_throw_release_marker_local, _throw_follow_control_marker_local, _throw_follow_marker_local, follow_weight)
_update_throw_torso(0.35 * (1.0 - follow_weight))
if _throw_released and _throw_elapsed >= release_time + THROW_FOLLOW_THROUGH:
_phase = Phase.IDLE
_throw_released = false
_active_action.clear()
_restore_hand_target()
_restore_throw_torso()
action_completed.emit()
func _update_throw_torso(weight: float) -> void:
var torso_marker := _torso_marker()
if torso_marker == null:
return
torso_marker.position = _throw_torso_start.lerp(_throw_torso_windup, weight)
torso_marker.rotation = lerpf(_throw_torso_start_rotation, _throw_torso_windup_rotation, weight)
func _restore_throw_torso() -> void:
var torso_marker := _torso_marker()
if torso_marker != null:
torso_marker.position = _throw_torso_start
torso_marker.rotation = _throw_torso_start_rotation
func _solve_throw(release_transform: Transform2D, target: Vector2, prop: PropBlock, spin_speed: float) -> Dictionary:
var start := release_transform.origin
var delta_position := target - start
if not start.is_finite() or not target.is_finite() or not delta_position.is_finite():
return {}
if delta_position.is_zero_approx():
return {}
var gravity_direction: Vector2 = ProjectSettings.get_setting("physics/2d/default_gravity_vector", Vector2.DOWN)
var gravity_magnitude := float(ProjectSettings.get_setting("physics/2d/default_gravity", 980.0)) * prop.gravity_scale
if not gravity_direction.is_finite() or not is_finite(gravity_magnitude) or not is_finite(spin_speed):
return {}
var gravity := gravity_direction.normalized() * gravity_magnitude
if not gravity.is_finite():
return {}
var flight_time := _shortest_valid_throw_time(delta_position, gravity)
if flight_time <= 0.0:
return {}
var velocity := (delta_position - 0.5 * gravity * flight_time * flight_time) / flight_time
if not velocity.is_finite() or velocity.length() > MAX_THROW_SPEED + 0.01:
return {}
return _make_throw_solution(start, target, velocity, gravity, release_transform.get_rotation(), spin_speed, flight_time)
func _shortest_valid_throw_time(displacement: Vector2, gravity: Vector2) -> float:
var gravity_squared := gravity.length_squared()
var lower_time := MIN_THROW_FLIGHT_TIME
var upper_time := THROW_PREVIEW_HORIZON
if gravity_squared > 0.000001:
var a := 0.25 * gravity_squared
var b := -(displacement.dot(gravity) + MAX_THROW_SPEED * MAX_THROW_SPEED)
var c := displacement.length_squared()
var discriminant := b * b - 4.0 * a * c
if discriminant < 0.0:
return 0.0
var root := sqrt(maxf(discriminant, 0.0))
var lower_time_squared := (-b - root) / (2.0 * a)
var upper_time_squared := (-b + root) / (2.0 * a)
if upper_time_squared <= 0.0:
return 0.0
lower_time = maxf(lower_time, sqrt(maxf(lower_time_squared, 0.0)))
upper_time = minf(upper_time, sqrt(upper_time_squared))
var down_direction := gravity / sqrt(gravity_squared)
var down_displacement := displacement.dot(down_direction)
if down_displacement < 0.0:
var descending_time := sqrt(-2.0 * down_displacement / sqrt(gravity_squared)) + THROW_DESCENT_EPSILON
lower_time = maxf(lower_time, descending_time)
else:
lower_time = maxf(lower_time, displacement.length() / MAX_THROW_SPEED)
if lower_time > upper_time or lower_time > THROW_PREVIEW_HORIZON:
return 0.0
return lower_time
func _make_throw_solution(start: Vector2, target: Vector2, velocity: Vector2, gravity: Vector2, release_rotation: float, spin_speed: float, flight_time: float) -> Dictionary:
var points := PackedVector2Array()
for index: int in THROW_TRAJECTORY_SEGMENTS + 1:
var t := flight_time * float(index) / float(THROW_TRAJECTORY_SEGMENTS)
points.append(start + velocity * t + gravity * (0.5 * t * t))
return {
"velocity": velocity,
"landing_position": target,
"start_position": start,
"effective_target": target,
"release_rotation": release_rotation,
"spin_speed": spin_speed,
"flight_time": flight_time,
"trajectory_points": points,
}
func _predicted_attachment_transform(wrist_position: Vector2, facing_left: bool, prop_override: PropBlock = null, anchor_override: Dictionary = {}, hand_override: int = -1, root_offset: Vector2 = Vector2.ZERO) -> Transform2D:
var prop := _held_prop if prop_override == null else prop_override
var anchor := _held_anchor if anchor_override.is_empty() else anchor_override
var hand_value := int(_hand) if hand_override < 0 else hand_override
if _rig == null or prop == null or not is_instance_valid(prop):
return Transform2D(0.0, wrist_position)
var suffix := "Left" if hand_value == Hand.LEFT else "Right"
var lower_visual := _rig.get_node_or_null(NodePath("Body/%sLowerArm" % suffix)) as Node2D
var elbow_world := _solved_wrist_transform_for(hand_value).origin
if lower_visual != null:
elbow_world = lower_visual.global_position
var rig_transform := _rig_transform_for_facing(facing_left, root_offset)
var elbow_position := rig_transform * _rig.to_local(elbow_world)
var arm_direction := wrist_position - elbow_position
var arm_angle := arm_direction.angle() if not arm_direction.is_zero_approx() else _solved_wrist_transform_for(hand_value).get_rotation()
var wrist_transform := Transform2D(arm_angle, wrist_position)
return ANCHOR_DATA.solve_attachment_transform(wrist_transform, _scaled_anchor(anchor, prop), Vector2.ONE, facing_left)
func _clip_preview_to_collision(solution: Dictionary, prop: PropBlock) -> Dictionary:
if prop == null or not is_instance_valid(prop):
return solution
var collision_shape: Shape2D
var collision_local_transform := Transform2D.IDENTITY
var polygon_node := prop.get_node_or_null(NodePath("CollisionPolygon2D")) as CollisionPolygon2D
if polygon_node != null and polygon_node.polygon.size() >= 3:
var convex_points := Geometry2D.convex_hull(polygon_node.polygon)
if convex_points.size() >= 3:
var convex_shape := ConvexPolygonShape2D.new()
convex_shape.points = convex_points
collision_shape = convex_shape
collision_local_transform = prop.global_transform.affine_inverse() * polygon_node.global_transform
if polygon_node.disabled:
collision_shape = null
var circle_node := prop.get_node_or_null(NodePath("CollisionShape2D")) as CollisionShape2D
if collision_shape == null and circle_node != null and circle_node.shape != null and not circle_node.disabled:
collision_shape = circle_node.shape
collision_local_transform = prop.global_transform.affine_inverse() * circle_node.global_transform
if collision_shape == null:
return solution
var collision_mask := int(_saved_body_state.get("collision_mask", prop.collision_mask))
if collision_mask == 0:
return solution
var points: PackedVector2Array = solution.get("trajectory_points", PackedVector2Array())
if points.size() < 2:
return solution
var space_state := prop.get_world_2d().direct_space_state
if space_state == null:
return solution
var excluded: Array[RID] = [prop.get_rid()]
if _rig != null and _rig.has_method("get_collision_body_rids"):
for body_rid: RID in _rig.call("get_collision_body_rids"):
if body_rid.is_valid():
excluded.append(body_rid)
var clipped_points := PackedVector2Array([points[0]])
var impact_position := points[-1]
var impact_time := float(solution.get("flight_time", THROW_PREVIEW_HORIZON))
var collision_position := Vector2.INF
var collision_before_target := false
var segment_count := points.size() - 1
var horizon := float(solution.get("flight_time", THROW_PREVIEW_HORIZON))
var start_rotation := float(solution.get("release_rotation", 0.0))
for index: int in segment_count:
var from_position := points[index]
var to_position := points[index + 1]
var t0 := horizon * float(index) / float(segment_count)
var dt := horizon / float(segment_count)
var query := PhysicsShapeQueryParameters2D.new()
query.shape = collision_shape
query.transform = Transform2D(start_rotation + float(solution.get("spin_speed", THROW_SPIN_SPEED)) * t0, from_position) * collision_local_transform
query.motion = to_position - from_position
query.collision_mask = collision_mask
query.exclude = excluded
var cast := space_state.cast_motion(query)
if cast.size() >= 2 and cast[0] < 0.9999:
var safe_fraction := clampf(cast[0], 0.0, 1.0)
impact_position = from_position.lerp(to_position, safe_fraction)
impact_time = t0 + dt * safe_fraction
collision_position = impact_position
collision_before_target = true
clipped_points.append(impact_position)
break
clipped_points.append(to_position)
var clipped := solution.duplicate(true)
clipped["landing_position"] = impact_position
clipped["flight_time"] = impact_time
clipped["trajectory_points"] = clipped_points
clipped["collision_position"] = collision_position
clipped["collision_before_target"] = collision_before_target
return clipped
func _quadratic_bezier(from: Vector2, control: Vector2, to: Vector2, weight: float) -> Vector2:
var t := clampf(weight, 0.0, 1.0)
var inverse := 1.0 - t
return from * inverse * inverse + control * (2.0 * inverse * t) + to * t * t
func _release_held(reason: String, throwing: bool, throw_velocity: Vector2 = Vector2.ZERO, throw_spin: float = 0.0) -> void:
if _held_prop == null or not is_instance_valid(_held_prop):
_clear_held_state()
return
var prop := _held_prop
var release_velocity := throw_velocity if throwing else _sampled_linear_velocity
var release_angular := _sampled_angular_velocity
if not release_velocity.is_finite():
release_velocity = Vector2.ZERO
if not is_finite(release_angular):
release_angular = 0.0
prop.interaction_owner_id = 0
prop.interaction_reserved_by = 0
prop.collision_layer = int(_saved_body_state.get("collision_layer", 1))
prop.collision_mask = int(_saved_body_state.get("collision_mask", 1))
prop.z_index = int(_saved_body_state.get("z_index", prop.z_index))
prop.z_as_relative = bool(_saved_body_state.get("z_as_relative", prop.z_as_relative))
prop.freeze_mode = int(_saved_body_state.get("freeze_mode", RigidBody2D.FREEZE_MODE_KINEMATIC))
prop.freeze = false if throwing else bool(_saved_body_state.get("freeze", false))
if throwing:
prop.sleeping = false
prop.linear_velocity = release_velocity
prop.angular_velocity = throw_spin
elif not prop.freeze:
prop.sleeping = false
prop.linear_velocity = release_velocity
prop.angular_velocity = release_angular
_held_prop = null
_held_anchor.clear()
_saved_body_state.clear()
_sampled_linear_velocity = Vector2.ZERO
_sampled_angular_velocity = 0.0
if not throwing:
_restore_hand_target()
_phase = Phase.IDLE if not throwing else Phase.THROWING
if reason == "manual" or reason == "ragdoll":
prop_event.emit("prop_dropped", prop, reason)
elif reason == "throw":
prop_event.emit("prop_thrown", prop, "")
func _clear_reservation() -> void:
if _held_prop != null and is_instance_valid(_held_prop):
if _held_prop.interaction_reserved_by == _rig.get_instance_id():
_held_prop.interaction_reserved_by = 0
_held_prop = null
_held_anchor.clear()
_active_action.clear()
func _clear_held_state() -> void:
_restore_pickup_torso()
_held_prop = null
_held_anchor.clear()
_saved_body_state.clear()
_phase = Phase.IDLE
_active_action.clear()
func _restore_hand_target() -> void:
if _rig == null:
return
var marker := _hand_marker()
if marker != null:
marker.position = _marker_start_position
func _hand_marker() -> Marker2D:
return _hand_marker_for(_hand)
func _torso_marker() -> Marker2D:
return _rig.get_node_or_null(NodePath("IK_Targets/Torso")) as Marker2D if _rig != null else null
func _hand_marker_for(hand: int) -> Marker2D:
if _rig == null:
return null
var path := "IK_Targets/Left_Hand" if hand == Hand.LEFT else "IK_Targets/Right_Hand"
return _rig.get_node_or_null(NodePath(path)) as Marker2D
func _solved_wrist_transform() -> Transform2D:
return _solved_wrist_transform_for(_hand)
func _solved_wrist_transform_for(hand: Hand) -> Transform2D:
var suffix := "Left" if hand == Hand.LEFT else "Right"
var lower_visual := _rig.get_node_or_null(NodePath("Body/%sLowerArm" % suffix)) as Node2D
var lower_bone := _rig.get_node_or_null(NodePath("Skeleton2D/Torso/%sUpperArm/%sLowerArm" % [suffix, suffix])) as Bone2D
if lower_visual == null or lower_bone == null:
var marker := _hand_marker_for(hand)
return Transform2D(0.0, marker.global_position if marker != null else _rig.global_position)
var elbow := lower_visual.to_global(Vector2.ZERO)
var wrist := lower_visual.to_global(Vector2(0.0, lower_bone.length))
var direction := (wrist - elbow).normalized()
return Transform2D(direction.angle(), wrist)
func _scaled_anchor(anchor: Dictionary, prop: PropBlock) -> Dictionary:
var result := anchor.duplicate(true)
result["position"] = (anchor.get("position", Vector2.ZERO) as Vector2) * prop.get_interaction_scale()
return result
func _grip_world_position(prop: PropBlock, anchor: Dictionary) -> Vector2:
return _grip_world_position_for_facing(prop, anchor, _is_facing_left())
func _grip_world_position_for_facing(prop: PropBlock, anchor: Dictionary, facing_left: bool) -> Vector2:
var scaled := _scaled_anchor(anchor, prop)
var local_position: Vector2 = scaled.get("position", Vector2.ZERO)
if facing_left:
local_position.x = -local_position.x
return prop.global_transform * local_position
func _is_facing_left() -> bool:
return _rig != null and int(_rig.call("get_facing_profile")) == 0