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