Make a 2D Grappling Hook Game in Unity – Part 1

Learn how to implement your own 2D grappling hook system in this Unity tutorial. By Sean Duffy.

Leave a rating/review
Save for later
Share
You are currently viewing page 2 of 3 of this article. Click here to view the first page.

Adding Rope

That slug isn't going to get airborne without a rope, so now would be a good time to give him something that visually represents a rope, and also has the ability to “wrap” around angles.

A line renderer is perfect for this, because it allows you to provide the amount of points and their positions in world space.

The idea here is that you'll always keep the rope's first vertex (0) on the player's position, and all other vertices will be positioned dynamically wherever the rope needs to wrap around, including the current pivot position that is the next point down the rope from the player.

Select Player and add a LineRenderer component to it. Set the Width to 0.075. Expand the Materials rollout and for Element 0, choose the RopeMaterial material, included in the project's Materials folder. Lastly for the Line Renderer, select Distribute Per Segment under the Texture Mode selection.

Drag the Line Renderer component to Rope System's Rope Renderer field.

Click the Rope Layer Mask drop down, and choose Default, Rope, and Pivot as the layers that the raycast can interact with. This will ensure that when the raycast is made, it'll only collide with these layers, and not with other things such as the player.

If you run the game now, you may notice some strange behavior. Aiming above the slug at the rock overhead and firing the grappling hook results in a small hop upwards, followed by our slippery fellow acting rather erratically.

The distance joint's distance is not being set, and the line renderer vertices are not being configured either. Therefore, you don't see a rope and because the distance joint is sitting right on top of the slug's position, the current distance joint distance value pushes him down into the rocks below.

Not to worry though, you'll sort that out now.

In the RopeSystem.cs script, add a new using statement at the top of the class:

using System.Linq;

This enables you to use LINQ queries, which in your case will simply allow you to easily find the first or last item in the ropePositions list.

Note: Language-Integrated Query (LINQ) is the name for a set of technologies based on the integration of query capabilities directly into the C# language. More information can be found here.

Add a new private bool variable called distanceSet below the other variables:

private bool distanceSet;

You'll use this as a flag to let the script know that the rope's distance (for the point between the player and the current pivot where the grappling hook is attached) has been set correctly.

Now add a new method that you'll use to set the rope vertex positions on the line renderer, and configure the distance joint's distance based on the stored list of rope positions you'll be maintaining (ropePositions):

private void UpdateRopePositions()
{
    // 1
    if (!ropeAttached)
    {
        return;
    }

    // 2
    ropeRenderer.positionCount = ropePositions.Count + 1;

    // 3
    for (var i = ropeRenderer.positionCount - 1; i >= 0; i--)
    {
        if (i != ropeRenderer.positionCount - 1) // if not the Last point of line renderer
        {
            ropeRenderer.SetPosition(i, ropePositions[i]);
                
            // 4
            if (i == ropePositions.Count - 1 || ropePositions.Count == 1)
            {
                var ropePosition = ropePositions[ropePositions.Count - 1];
                if (ropePositions.Count == 1)
                {
                    ropeHingeAnchorRb.transform.position = ropePosition;
                    if (!distanceSet)
                    {
                        ropeJoint.distance = Vector2.Distance(transform.position, ropePosition);
                        distanceSet = true;
                    }
                }
                else
                {
                    ropeHingeAnchorRb.transform.position = ropePosition;
                    if (!distanceSet)
                    {
                        ropeJoint.distance = Vector2.Distance(transform.position, ropePosition);
                        distanceSet = true;
                    }
                }
            }
            // 5
            else if (i - 1 == ropePositions.IndexOf(ropePositions.Last()))
            {
                var ropePosition = ropePositions.Last();
                ropeHingeAnchorRb.transform.position = ropePosition;
                if (!distanceSet)
                {
                    ropeJoint.distance = Vector2.Distance(transform.position, ropePosition);
                    distanceSet = true;
                }
            }
        }
        else
        {
            // 6
            ropeRenderer.SetPosition(i, transform.position);
        }
    }
}

Explaining the above code:

  1. Return out of this method if the rope isn't actually attached.
  2. Set the rope's line renderer vertex count (positions) to whatever number of positions are stored in ropePositions, plus 1 more (for the player's position).
  3. Loop backwards through the ropePositions list, and for every position (except the last position), set the line renderer vertex position to the Vector2 position stored at the current index being looped through in ropePositions.
  4. Set the rope anchor to the second-to-last rope position where the current hinge/anchor should be, or if there is only one rope position, then set that one to be the anchor point. This configures the ropeJoint distance to the distance between the player and the current rope position being looped over.
  5. This if-statement handles the case where the rope position being looped over is the second-to-last one; that is, the point at which the rope connects to an object, a.k.a. the current hinge/anchor point.
  6. This else block handles setting the rope's last vertex position to the player's current position.

Don't forget to add a call to UpdateRopePositions() at the bottom of Update():

UpdateRopePositions();

Save the changes to your script and run the game again. Make a little jump with space bar, while aiming and firing at the rock above you. You can now admire the fruits of your labor as you watch the slug dangle peacefully above the rocks.

grappling hook attached

You can also switch to the scene view, select the Player, use the move tool (W by default) to move him around and watch how the rope line renderer's two vertices follows the grapple position and the player's position to draw the rope. Letting go of the player while moving him will result in the DistanceJoint2D re-configuring the distance correctly, and the slug will continue swinging by the connected joint.

grappling hook from scene view

Handling Wrap Points

A dangling slug game is about as useful as a waterproof towel, so you'll definitely need to build on what you've got so far.

The good news is that the method you just added to handle rope positions is future proof. You're currently only using two rope positions. One connected to the player's position, and one to the current grapple pivot position when you fire the grappling hook out.

The only problem is you're not yet tracking all rope potential rope positions, and you'll need to do a little bit of work to get there.

In order to detect positions on the rocks where the rope should wrap around and add a new vertex position to the line renderer, you'll need a system to determine if a collider vertex point lies in between a straight line between the slug's current position, and the current rope hinge/anchor point.

Sounds like a job for the good old raycast once again!

First, you'll need to build a method that can find the closest point in a collider based on the hit location of a raycast and the edges of the collider.

In your RopeSystem.cs script, add this new method:

// 1
private Vector2 GetClosestColliderPointFromRaycastHit(RaycastHit2D hit, PolygonCollider2D polyCollider)
{
    // 2
    var distanceDictionary = polyCollider.points.ToDictionary<Vector2, float, Vector2>(
        position => Vector2.Distance(hit.point, polyCollider.transform.TransformPoint(position)), 
        position => polyCollider.transform.TransformPoint(position));

    // 3
    var orderedDictionary = distanceDictionary.OrderBy(e => e.Key);
    return orderedDictionary.Any() ? orderedDictionary.First().Value : Vector2.zero;
}

If you’re not a LINQ query whiz, this may look like some whimsical magical C# wizardry to you.

If that's the case, don't be too scared. LINQ is doing a lot of stuff under the hood for you:

  1. This method takes in two parameters, a RaycastHit2D object, and a PolygonCollider2D. All the rocks in the level have PolygonCollider2D colliders, so this will work well as long as you're always using PolygonCollider2D shapes.
  2. Here be LINQ query magic! This converts the polygon collider's collection of points, into a dictionary of Vector2 positions (the value of each dictionary entry is the position itself), and the key of each entry, is set to the distance that this point is to the player's position (float value). Something else happens here: the resulting position is transformed into world space (by default a collider's vertex positions are stored in local space - i.e. local to the object the collider sits on, and we want the world space positions).
  3. The dictionary is ordered by key. In other words, the distance closest to the player's current position, and the closest one is returned, meaning that whichever point is returned from this method, is the point on the collider between the player and the current hinge point on the rope!

Back in your RopeSystem.cs script, add a new private field variable to the top:

private Dictionary<Vector2, int> wrapPointsLookup = new Dictionary<Vector2, int>();

You'll use this to track the positions that the rope should be wrapping around.

In Update(), locate the else statement you left near the bottom containing the crosshairSprite.enabled = false; statement and add the following:

// 1
if (ropePositions.Count > 0)
{
    // 2
    var lastRopePoint = ropePositions.Last();
    var playerToCurrentNextHit = Physics2D.Raycast(playerPosition, (lastRopePoint - playerPosition).normalized, Vector2.Distance(playerPosition, lastRopePoint) - 0.1f, ropeLayerMask);
    
    // 3
    if (playerToCurrentNextHit)
    {
        var colliderWithVertices = playerToCurrentNextHit.collider as PolygonCollider2D;
        if (colliderWithVertices != null)
        {
            var closestPointToHit = GetClosestColliderPointFromRaycastHit(playerToCurrentNextHit, colliderWithVertices);

            // 4
            if (wrapPointsLookup.ContainsKey(closestPointToHit))
            {
                ResetRope();
                return;
            }

            // 5
            ropePositions.Add(closestPointToHit);
            wrapPointsLookup.Add(closestPointToHit, 0);
            distanceSet = false;
        }
    }
}

Explaining the above chunk of code:

  1. If the ropePositions list has any positions stored, then...
  2. Fire a raycast out from the player's position, in the direction of the player looking at the last rope position in the list — the pivot point where the grappling hook is hooked into the rock — with a raycast distance set to the distance between the player and rope pivot position.
  3. If the raycast hits something, then that hit object's collider is safe cast to a PolygonCollider2D. As long as it's a real PolygonCollider2D, then the closest vertex position on that collider is returned as a Vector2, using that handy-dandy method you wrote earlier.
  4. The wrapPointsLookup is checked to make sure the same position is not being wrapped again. If it is, then it'll reset the rope and cut it, dropping the player.
  5. The ropePositions list is now updated, adding the position the rope should wrap around, and the wrapPointsLookup dictionary is also updated. Lastly the distanceSet flag is disabled, so that UpdateRopePositions() method can re-configure the rope's distances to take into account the new rope length and segments.

In ResetRope(), add this to clear the wrapPointsLookup dictionary each time the player disconnects the rope:

wrapPointsLookup.Clear();

Save and run the game. Fire the grappling hook at the rock above, and use the Move tool in the Scene view to move the slug past a few rocky outcrops.

And that is how you get a slug and a rope to wrap!