Creating a cat hook in Unity. Part 1

image

Cat hooks add funny and interesting mechanics to the game. You can use them to move through the levels, battles in arenas and get items. But despite the seeming simplicity, the physics of managing ropes and creating realistic behavior can be challenging!

In the first part of this tutorial, we will implement our own two-dimensional hook-cat system and learn the following:


Note : This tutorial is intended for advanced and advanced users, and it does not cover such topics as adding components, creating new GameObject scripts and C # syntax. If you need to improve your Unity skills, then learn our tutorials Getting Started with Unity and Introduction to Unity Scripting . Since this tutorial uses DistanceJoint2D , you should also look at Physics Joints in Unity 2D , and only then go back to this tutorial.

Getting Started


Download the project template for this tutorial, and then open it in the Unity editor. To work, you need a version of Unity 2017.1 or higher.

Open the Game scene from the Scenes folder and see where we start:


While we have a simple player character (slug) and stones hanging in the air.

Important components of GameObject Player are capsule collider and rigidbody, which allow it to interact with physical objects on the level. Also attached to the character is a simple movement script ( PlayerMovement ), which allows it to slide on the ground and perform simple jumps.

Click on the Play button to start the game, and try to control the character. A and D move it left / right, and when you press the spacebar, it makes a jump. Try not to slide off or fall off a cliff, otherwise you will die!


We already have the basics of management, but the biggest problem now is the absence of hooks-cats.

Making hooks and ropes


At first, the cat-hook system seems fairly simple, but for its quality implementation, there are many aspects to consider. Here are some of the requirements for the two-dimensional mechanics of hook cats:


In the Hierarchy, select the Player object and add a new child GameObject called RopeHingeAnchor . This GameObject will be used to position the suspension / pivot point of the hook cat.

Add SpriteRenderer and RigidBody2D to RopeHingeAnchor .

For SpriteRenderer, set the Sprite property to use the UISprite value and change the Order in Layer to 2 . Disable the component by unchecking the box next to its name.

For the RigidBody2D component , set the Body Type property to Kinematic . This point will not be moved by the physics engine, but by the code.

Select the Rope layer and set the X and Y scale values ​​of the Transform component to 4 .


Select the Player again and attach the new component DistanceJoint2D .

Drag RopeHingeAnchor from the Hierarchy onto the Connected Rigid Body property of the DistanceJoint2D component and disable Auto Configure Distance .


Create a new C # script with the name RopeSystem in the Scripts project folder and open it in the code editor.

Remove the Update method.

At the top of the script inside the declaration of the RopeSystem class RopeSystem add new variables, the Awake() method and the new Update method:

 // 1 public GameObject ropeHingeAnchor; public DistanceJoint2D ropeJoint; public Transform crosshair; public SpriteRenderer crosshairSprite; public PlayerMovement playerMovement; private bool ropeAttached; private Vector2 playerPosition; private Rigidbody2D ropeHingeAnchorRb; private SpriteRenderer ropeHingeAnchorSprite; void Awake() { // 2 ropeJoint.enabled = false; playerPosition = transform.position; ropeHingeAnchorRb = ropeHingeAnchor.GetComponent<Rigidbody2D>(); ropeHingeAnchorSprite = ropeHingeAnchor.GetComponent<SpriteRenderer>(); } void Update() { // 3 var worldMousePosition = Camera.main.ScreenToWorldPoint(new Vector3(Input.mousePosition.x, Input.mousePosition.y, 0f)); var facingDirection = worldMousePosition - transform.position; var aimAngle = Mathf.Atan2(facingDirection.y, facingDirection.x); if (aimAngle < 0f) { aimAngle = Mathf.PI * 2 + aimAngle; } // 4 var aimDirection = Quaternion.Euler(0, 0, aimAngle * Mathf.Rad2Deg) * Vector2.right; // 5 playerPosition = transform.position; // 6 if (!ropeAttached) { } else { } } 

Let's sort each part in order:

  1. We use these variables to track the various components with which the RopeSystem script will interact.
  2. The Awake method starts at the beginning of the game and disables the ropeJoint (component DistanceJoint2D). It also sets playerPosition to the current position of the Player.
  3. This is the most important part of the main Update() loop. First we get the mouse position in the world using the ScreenToWorldPoint camera ScreenToWorldPoint . Then we calculate the direction of the view, subtracting the position of the player from the position of the mouse in the world. Then use it to create aimAngle , which is a representation of the aiming angle of the cursor. Value saves a positive value in the if construct.
  4. aimDirection is a turnaround that comes in handy later. We are only interested in the value of Z, because we use a 2D camera, and this is the only corresponding OS. We pass aimAngle * Mathf.Rad2Deg , which converts a radian angle to an angle in degrees.
  5. The position of the player is tracked using a convenient variable that allows you not to constantly refer to transform.Position .
  6. Finally, we have an if..else construct, which we will soon use to determine if a rope is attached to a support point.

Save the script and return to the editor.

Attach the RopeSystem component to the Player object and attach the various components to the public fields that we created in the RopeSystem script. Drag Player , Crosshair and RopeHingeAnchor to the appropriate fields:



Now we just perform all these intricate calculations, but so far there is no visualization that could show them in action. But do not worry, we will deal with it soon.

Open the RopeSystem script and add a new method to it:

 private void SetCrosshairPosition(float aimAngle) { if (!crosshairSprite.enabled) { crosshairSprite.enabled = true; } var x = transform.position.x + 1f * Mathf.Cos(aimAngle); var y = transform.position.y + 1f * Mathf.Sin(aimAngle); var crossHairPosition = new Vector3(x, y, 0); crosshair.transform.position = crossHairPosition; } 

This method positions the sight based on the transmitted aimAngle (the float value that we calculated in Update() ) so that it rotates around the player with a radius of 1 unit. We also include a sprite sight in case this has not yet been done.

In Update() we change the !ropeAttached construct that checks !ropeAttached so that it looks like this:

 if (!ropeAttached) { SetCrosshairPosition(aimAngle); } else { crosshairSprite.enabled = false; } 

Save the script and run the game. Now our slug should be able to aim with the help of a sight.


The next part of the logic that needs to be implemented is a shot with a cat hook. We have already determined the direction of aiming, so we need a method that will receive it as a parameter.

Add the following variables under the variables in the RopeSystem script:

 public LineRenderer ropeRenderer; public LayerMask ropeLayerMask; private float ropeMaxCastDistance = 20f; private List<Vector2> ropePositions = new List<Vector2>(); 

LineRenderer will contain a link to render lines that draws the rope. LayerMask allows you to customize the layers of physics with which the hook can interact. The value ropeMaxCastDistance sets the maximum distance that a raycast can “shoot”.

Finally, the Vector2 position list will be used to track the wrapping points of the rope, which we will look at later.

Add the following new methods:

 // 1 private void HandleInput(Vector2 aimDirection) { if (Input.GetMouseButton(0)) { // 2 if (ropeAttached) return; ropeRenderer.enabled = true; var hit = Physics2D.Raycast(playerPosition, aimDirection, ropeMaxCastDistance, ropeLayerMask); // 3 if (hit.collider != null) { ropeAttached = true; if (!ropePositions.Contains(hit.point)) { // 4 //    ,    -  . transform.GetComponent<Rigidbody2D>().AddForce(new Vector2(0f, 2f), ForceMode2D.Impulse); ropePositions.Add(hit.point); ropeJoint.distance = Vector2.Distance(playerPosition, hit.point); ropeJoint.enabled = true; ropeHingeAnchorSprite.enabled = true; } } // 5 else { ropeRenderer.enabled = false; ropeAttached = false; ropeJoint.enabled = false; } } if (Input.GetMouseButton(1)) { ResetRope(); } } // 6 private void ResetRope() { ropeJoint.enabled = false; ropeAttached = false; playerMovement.isSwinging = false; ropeRenderer.positionCount = 2; ropeRenderer.SetPosition(0, transform.position); ropeRenderer.SetPosition(1, transform.position); ropePositions.Clear(); ropeHingeAnchorSprite.enabled = false; } 

Here is what the above code does:

  1. HandleInput is called from the Update() loop and simply polls the input of the left and right mouse buttons.
  2. When the left mouse button is registered, the render of the rope line is turned on and a 2D-raycast is fired from the player's position in the direction of aiming. The maximum distance is set so that the hook cat cannot be fired at an infinite distance, and a mask is applied so that you can select the layers of physics that the raycast is capable of colliding with.
  3. If a raycast hit is detected, then ropeAttached is true , and a list of the positions of the vertices of the rope is checked to make sure that the points are not there yet.
  4. If the check returns true, then a small impulse of force is added to the slug so that it jumps over the ground, the ropeJoint (DistanceJoint2D) is ropeJoint , which is set to a distance equal to the distance between the slug and the raycast hit point. Also included is the base point sprite.
  5. If the raycast does not hit anything, then line renderer and ropeJoint are disabled, and the ropeAttached flag is false.
  6. If the right mouse button is pressed, the ResetRope() method is ResetRope() , which disables and resets all parameters related to the string / hook to the values ​​that should be if the hook is not used.

At the very bottom of our Update method, add a call to the new HandleInput() method and pass it the value of aimDirection :

 HandleInput(aimDirection); 

Save the changes to RopeSystem.cs and return to the editor.

Add rope


Our slug will not be able to fly through the air without a rope, so it's time to give him what will be a visual display of the rope and has the ability to "turn around" around the corners.

The line renderer is ideal for this, because it allows us to transfer the number of points and their positions in the space of the world.

The idea here is that we always store the first vertex of the rope (0) in the player's position, and all other vertices are positioned dynamically when the rope should turn around something, including the current position of the hinge, which is the next point along the rope from the player.

Select Player and add the LineRenderer component to it. Set the Width to 0.075 . Expand the Materials list and, as Element 0, select the RopeMaterial material located in the Materials folder of the project. Finally, in the Line Renderer, for the Texture Mode parameter, select Distribute Per Segment .


Drag the Line Renderer component into the Rope Renderer field of the Rope System component.

Click on the Rope Layer Mask drop-down list and select as layers with which raycast Default, Rope and Pivot can interact. Because of this, when “shooting” a raycast, it will only collide with these layers, but not with other objects, such as a player.


If you start the game now, you will notice strange behavior. When we aim at a stone over the head of a slug and shoot a hook, we get a small jump upwards, after which our friend begins to behave rather erratically.


We have not yet set the distance for distance joint, moreover, the vertices of the line render are not configured. Therefore, we do not see the rope, and since the distance joint is directly above the slug's position, the current distance distance distance joint pushes it down to the stones below it.

But do not worry, now we will solve this problem.

In the RopeSystem.cs script , add a new operator at the beginning of the class:

 using System.Linq; 

This allows us to use LINQ queries, which in our case simply allow us to conveniently find the first or last element of the ropePositions list.

Note : Language Integrated Query (LINQ) is the name of a set of technologies based on embedding query capabilities directly in C #. Read more about it here .

Add a new private variable bool with the name distanceSet under the other variables:

 private bool distanceSet; 

We will use this variable as a flag so that the script can know that the distance of the rope (for the point between the player and the current reference point where the hook-cat is attached) is set correctly.

Now we will add a new method that we will use to set the position of the rope tops for the line render and adjust the distance joint in the stored position list with the 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); } } } 

Explain the code shown above:

  1. Perform an exit from the method if the rope is not attached.
  2. Assign to the render point of the rope line the value of the number of positions stored in the ropePositions , plus 1 more (for the player’s position).
  3. We loop around in the reverse direction the list of ropePositions and for each position (except the last one), assign the position of the vertex of the line's render to the value of Vector2 position stored by the loop index in the list of ropePositions .
  4. We assign the second position of the rope to the rope's anchor point, in which the current joint / anchor point should be located, or if we have only one rope position, then we make it a reference point. So we set the distance of the ropeJoint equal to the distance between the player and the current position of the rope, which we ropeJoint in a loop.
  5. The if construct handles the case where the current position of the rope in the loop is second to the end; that is, the point at which the rope connects with the object, i.e. current joint / reference point.
  6. This else block handles the assignment of the position of the last vertex of the rope to the value of the current position of the player.

Don't forget to add an UpdateRopePositions() call at the end of Update() UpdateRopePositions() :

 UpdateRopePositions(); 

Save the changes in the script and run the game again. Make a small jump with the "space", aiming and shooting a hook at the stone above the character. Now you can enjoy the fruits of your labors - the slug quietly sways above the stones.


Now you can go to the scene window, select the Player, use the move tool (the W key by default) to move it and observe how the two vertices of the rope line follow the position of the hook and the position of the player to draw the rope. After we release the player, DistanceJoint2D correctly calculates the distance and the slug will continue to swing on the connected hinge.


Handling Wrap Points


Playing with a swinging slug is no longer useful as a water-repellent towel, so we definitely need to add it.


The good news is that the newly added method of processing rope positions can be used in the future. For now we use only two rope positions. One is connected to the player’s position, and the second to the current position of the hook's reference point when fired.

The only problem is that while we are not tracking all the potential positions of the rope, we need to work a little on this.

To recognize positions on stones around which the rope should be wrapped, adding a new vertex position to the render line, we need a system that determines whether the vertex point of the collider is between a straight line between the current slug position and the current hinge / anchor point of the rope.

Looks like this is work again for the good old raycast!


To begin, we need to create a method that can find the nearest point in the collider based on the hit point of the raycast and the collider faces.

Add a new method to the RopeSystem.cs script:

 // 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 LINQ queries are not familiar to you, then this code may seem like some complicated C # magic.


If so, do not be afraid. LINQ independently does a lot of work for us:

  1. This method takes two parameters — a RaycastHit2D object and a PolygonCollider2D . All stones on the level have colliders PolygonCollider2D, so if we always use the shapes PolygonCollider2D, it will work fine.
  2. This is where the magic of LINQ queries begins! Here we convert the collection of points of the polygonal collider into the Vector2 position dictionary (the value of each dictionary element is the position itself), and the key of each element is assigned the distance from this point to the player’s position player (float value). Sometimes something else happens here: the resulting position is converted into the space of the world (by default, the vertex positions of the collider are stored in local space, ie, local relative to the object that owns the collider, and we need positions in the space of the world).
  3. The dictionary is sorted by keys. In other words, by the distance closest to the current position of the player. The closest distance is returned, that is, any point returned by this method is a collider point between the player and the current hinge point of the rope!

Let's go back to the RopeSystem.cs script and add a new private field variable at the top:

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

We will use it to track the positions around which the rope can turn.

At the end of the Update() method, find the else , which contains crosshairSprite.enabled = false; 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; } } } 

We will explain this code snippet:

  1. If any positions are stored in the ropePositions list, then ...
  2. We shoot from the player's position in the direction of the player looking at the last position of the rope from the list — the anchor point at which the hook cat clings to the stone — with a raycast distance equal to the distance between the player and the position of the anchor point of the rope.
  3. If a raycast encounters something, then the collider of this object is safely converted to the type PolygonCollider2D . While it is real PolygonCollider2D, the closest position of the vertex of this collider is returned using the method we wrote earlier as Vector2 .
  4. It is checked by wrapPointsLookup to ensure that the same position is not checked again. If it is checked, then we drop the rope and cut it, dropping the player.
  5. Then the list of ropePositions updated: the position around which the rope should turn is added. The wrapPointsLookup dictionary is also updated. Finally, the distanceSet flag is reset so that the UpdateRopePositions() method can override the rope distances given the new rope length and segments.

In ResetRope() we add the following line so that the wrapPointsLookup dictionary wrapPointsLookup cleared each time the player disconnects the rope:

 wrapPointsLookup.Clear(); 

Save and run the game. Shoot the cat hook into the stone above the slug and use the Move tool in the Scene window to move the slug behind several stone projections.


This is how we taught the rope to turn around objects!

Add rocking ability


Slug hanging on a rope is pretty static. To fix this, we can add the rocking ability.

To do this, we need to get a position perpendicular to the swinging forward position (sideways), regardless of the angle at which he looks.

Open PlayerMovement.cs and add the following two public variables to the top of the script:

 public Vector2 ropeHook; public float swingForce = 4f; 

The variable ropeHook will be assigned to any position in which the rope hook is currently located, and the swingForce is the value that we use to add the swing movement.

Replace the FixedUpdate() method FixedUpdate() new one:

 void FixedUpdate() { if (horizontalInput < 0f || horizontalInput > 0f) { animator.SetFloat("Speed", Mathf.Abs(horizontalInput)); playerSprite.flipX = horizontalInput < 0f; if (isSwinging) { animator.SetBool("IsSwinging", true); // 1 -          var playerToHookDirection = (ropeHook - (Vector2)transform.position).normalized; // 2 -  ,     Vector2 perpendicularDirection; if (horizontalInput < 0) { perpendicularDirection = new Vector2(-playerToHookDirection.y, playerToHookDirection.x); var leftPerpPos = (Vector2)transform.position - perpendicularDirection * -2f; Debug.DrawLine(transform.position, leftPerpPos, Color.green, 0f); } else { perpendicularDirection = new Vector2(playerToHookDirection.y, -playerToHookDirection.x); var rightPerpPos = (Vector2)transform.position + perpendicularDirection * 2f; Debug.DrawLine(transform.position, rightPerpPos, Color.green, 0f); } var force = perpendicularDirection * swingForce; rBody.AddForce(force, ForceMode2D.Force); } else { animator.SetBool("IsSwinging", false); if (groundCheck) { var groundForce = speed * 2f; rBody.AddForce(new Vector2((horizontalInput * groundForce - rBody.velocity.x) * groundForce, 0)); rBody.velocity = new Vector2(rBody.velocity.x, rBody.velocity.y); } } } else { animator.SetBool("IsSwinging", false); animator.SetFloat("Speed", 0f); } if (!isSwinging) { if (!groundCheck) return; isJumping = jumpInput > 0f; if (isJumping) { rBody.velocity = new Vector2(rBody.velocity.x, jumpSpeed); } } } 

, isSwinging , , , , .

  1. .
  2. , , playerToHookDirection . , .

RopeSystem.cs else if(!ropeAttached) Update() :

 playerMovement.isSwinging = true; playerMovement.ropeHook = ropePositions.Last(); 

if if(!ropeAttached) :

 playerMovement.isSwinging = false; 

PlayerMovement, , ( ) — , . , PlayerMovement.

, gizmos A D /:



- . , , , ?

RopeSystem :

 public float climbSpeed = 3f; private bool isColliding; 

climbSpeed , , isColliding , distance joint .

:

 private void HandleRopeLength() { // 1 if (Input.GetAxis("Vertical") >= 1f && ropeAttached && !isColliding) { ropeJoint.distance -= Time.deltaTime * climbSpeed; } else if (Input.GetAxis("Vertical") < 0f && ropeAttached) { ropeJoint.distance += Time.deltaTime * climbSpeed; } } 

if..elseif (/ W/S ), ropeAttached iscColliding ropeJoint , .

, Update() :

 HandleRopeLength(); 

isColliding .

:

 void OnTriggerStay2D(Collider2D colliderStay) { isColliding = true; } private void OnTriggerExit2D(Collider2D colliderOnExit) { isColliding = false; } 

MonoBehaviour.

Collider , OnTriggerStay2D , isColliding true . , , isColliding true .

OnTriggerExit2D , , false.

: OnTriggerStay2D , .

Where to go next?


W/S, - .


.

— - !

, , - , , , ! Great job.


— «», .

.

, ? wrapPointsLookup .

Source: https://habr.com/ru/post/414887/


All Articles