using UnityEngine;
using UnityEngine.Events;
using UnityEngine.InputSystem;
/// <summary>
/// GAME 1303 Tier 3 — Fire a rope, swing from it, reel in. Hot Air's core mechanic;
/// also usable by Spark of Corruption's Servant.
///
/// SETUP
/// 1. Add to any Rigidbody2D object (balloon or player). Add a LineRenderer (2 positions,
/// width 0.05) for the rope and drag it in. Add a DistanceJoint2D? NO — this script
/// adds one at runtime.
/// 2. Input: add a "Grapple" Button action to the Player map (Right Mouse / gamepad
/// Left Trigger). Aim:
/// Mouse – toward the mouse cursor (needs Camera.main; best for the balloon)
/// Facing – forward and up at Aim Angle degrees (PlayerController2D games)
/// Stick – Move vector direction (gamepad)
/// 3. Grapple Layer: what the hook can attach to (rocks, ceiling, anchors). Max Distance
/// is how far the rope can reach.
/// 4. Hold Move Up/Down (Move Y) to reel in/out while attached. Release the button to
/// let go. Jump also lets go if a PlayerController2D is present.
/// 5. OnAttached / OnReleased for SFX and animation.
///
/// Physics tip: swings feel best with Rigidbody2D Linear Damping 0.2–0.5 and the joint's
/// Max Distance Only = true (rope goes slack instead of acting like a rod).
/// </summary>
[RequireComponent(typeof(Rigidbody2D))]
public class GrapplingHook : MonoBehaviour
{
public enum Aim { Mouse, Facing, Stick }
public Aim aim = Aim.Mouse;
[Tooltip("Facing mode: degrees above horizontal.")]
public float aimAngle = 45f;
public float maxDistance = 10f;
public LayerMask grappleLayer;
[Header("Rope")]
public LineRenderer rope;
public float reelSpeed = 4f;
public float minRopeLength = 1f;
[Tooltip("Rope is a max length only (swing) vs. rigid rod.")]
public bool ropeGoesSlack = true;
[Tooltip("Small pull toward anchor on attach so the swing starts moving.")]
public float attachImpulse = 2f;
[Header("Events")]
public UnityEvent OnAttached;
public UnityEvent OnReleased;
public UnityEvent OnMissed;
public bool IsAttached => joint != null && joint.enabled;
public Vector2 AnchorPoint { get; private set; }
private Rigidbody2D rb;
private DistanceJoint2D joint;
private PlayerController2D controller;
private Vector2 moveInput;
void Awake()
{
rb = GetComponent<Rigidbody2D>();
controller = GetComponent<PlayerController2D>();
joint = gameObject.AddComponent<DistanceJoint2D>();
joint.enabled = false;
joint.autoConfigureDistance = false;
joint.enableCollision = true;
if (rope != null) rope.enabled = false;
}
void OnMove(InputValue v) { moveInput = v.Get<Vector2>(); }
void OnGrapple(InputValue v)
{
if (v.isPressed) TryAttach();
else Release();
}
void OnJump(InputValue v)
{
if (v.isPressed && IsAttached && controller != null)
{
Release();
controller.ForceJump(controller.jumpForce);
controller.ClearJumpBuffer();
}
}
public bool TryAttach()
{
if (IsAttached) return false;
Vector2 dir = GetAimDirection();
RaycastHit2D hit = Physics2D.Raycast(rb.position, dir, maxDistance, grappleLayer);
if (hit.collider == null) { OnMissed?.Invoke(); return false; }
AnchorPoint = hit.point;
joint.connectedBody = hit.rigidbody; // null = world anchor
joint.connectedAnchor = hit.rigidbody != null ? hit.rigidbody.transform.InverseTransformPoint(hit.point) : hit.point;
joint.anchor = Vector2.zero;
joint.distance = Vector2.Distance(rb.position, hit.point);
joint.maxDistanceOnly = ropeGoesSlack;
joint.enabled = true;
rb.AddForce(dir * attachImpulse, ForceMode2D.Impulse);
if (controller != null) controller.MovementLocked = true;
if (rope != null) rope.enabled = true;
OnAttached?.Invoke();
return true;
}
public void Release()
{
if (!IsAttached) return;
joint.enabled = false;
if (controller != null) controller.MovementLocked = false;
if (rope != null) rope.enabled = false;
OnReleased?.Invoke();
}
void FixedUpdate()
{
if (!IsAttached) return;
// Reel in/out
if (Mathf.Abs(moveInput.y) > 0.2f)
joint.distance = Mathf.Clamp(joint.distance - moveInput.y * reelSpeed * Time.fixedDeltaTime, minRopeLength, maxDistance);
// Gentle swing steering with left/right
if (Mathf.Abs(moveInput.x) > 0.2f)
rb.AddForce(Vector2.right * moveInput.x * 6f);
// Keep gravity normal for a PlayerController2D (it skips FixedUpdate while locked)
if (controller != null) rb.gravityScale = controller.BaseGravity;
}
void LateUpdate()
{
if (rope == null || !IsAttached) return;
Vector3 anchorWorld = joint.connectedBody != null
? joint.connectedBody.transform.TransformPoint(joint.connectedAnchor)
: (Vector3)joint.connectedAnchor;
rope.positionCount = 2;
rope.SetPosition(0, transform.position);
rope.SetPosition(1, anchorWorld);
}
Vector2 GetAimDirection()
{
switch (aim)
{
case Aim.Mouse:
if (Mouse.current != null && Camera.main != null)
{
Vector3 m = Camera.main.ScreenToWorldPoint(Mouse.current.position.ReadValue());
Vector2 d = (Vector2)m - rb.position;
if (d.sqrMagnitude > 0.01f) return d.normalized;
}
goto case Aim.Facing;
case Aim.Stick:
if (moveInput.sqrMagnitude > 0.1f) return moveInput.normalized;
goto case Aim.Facing;
default:
float facing = controller != null ? (controller.FacingRight ? 1f : -1f) : Mathf.Sign(transform.lossyScale.x);
float rad = aimAngle * Mathf.Deg2Rad;
return new Vector2(Mathf.Cos(rad) * facing, Mathf.Sin(rad)).normalized;
}
}
}