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