Wandi
Wandi

Reputation: 41

Ragdoll 2D Aiming

So I need help with starting something. I have a ragdoll for a 2D platformer that I've got moving and he's able to stand on his feet.

So now I have made a gun as a child of his forearm, and I want to have the character aim with his gun wielding hand using mouse input (x, y movement of the mouse). Where the player aims and shoots, that's where the projectile will travel. I currently don't know how to start with that.

My character movement mainly runs through physics, and I want it like that cause I want the recoil of my weapon to add force to the character so he can fly.

I haven't really done much besides, creating the empty GameObject for where my projectiles will spawn. Also I have created a script to read player input, then instantiate projectiles.

Upvotes: 3

Views: 85

Answers (1)

sashok
sashok

Reputation: 351

Alright, so you want to:

  1. Rotate the weapon in the mouse direction
  2. Shoot the bullets from the weapon
  3. Add a recoil force from the shot

There're indeed many methods to implement this, so let me show you how I'd do it my way. I assume you haven't done it yet, so I'll try to explain as clear as I can.


1. Create a player

I assume you have already implemented this logic, so that's how my player looks like

Player hierarchy

  • Player has a Rigidbody2D
  • Body, Arm and Weapon all have a BoxCollider2D
  • Handle is an empty GameObject, which has a Weapon inside of it. Changing the Handle's Z rotation, rotates the Weapon as you want

Player's handle


2. Rotate the weapon in the mouse direction

Rotate the weapon in the mouse direction

The α (alpha) represents the Z rotation of our handle in degrees, that's the value we need to get.

First, we get the mouse position and convert it from screen to world space.

Vector2 mousePos = Input.mousePosition;
Vector2 worldMousePos = _mainCamera.ScreenToWorldPoint(screenMousePos); // from screen to world space

_mainCamera will be our Camera.main, assigned in the Start method.

private void Start()
{
    _mainCamera = Camera.main;
}

To ensure we don't have issues with performance, we store the previous mouse position as _prevMousePos and check if our current isn't the same. This way we don't make any useless calculations, which affect the performance.

Vector2 mousePos = Input.mousePosition;

if (mousePos != _prevMousePos)
{
    // any further calculations are performed here

    _prevMousePos = mousePos;
}

As I drew on the image above, we first have to calculate the offset between the mouse and handle positions. This gives us the x and y sides of the imaginary triangle.

Vector2 offset = worldMousePos - (Vector2)transform.position;

Now, according to the tangent ratio, our formula looks like this, where x is offset.x and y is offset.y.

tangent formula

tan^{-1} is called arc-tangent, and there are 2 similar methods to do this. The explanations are taken from this methods' summaries.

Mathf.Atan(offset.y / offset.x); // Returns the arc-tangent of f - the angle in radians whose tangent is f
Mathf.Atan2(offset.y, offset.x); // Returns the angle in radians whose Tan is y/x

I'd suggest using Atan2. It returns an angle in radians, so we have to convert it to degrees, as this is what Unity's rotation eulers use. We can use Mathf.Rad2Deg (Unity's radians-to-degrees conversion constant).

Mathf.Atan2(offset.y, offset.x) * Mathf.Rad2Deg;

Now, we can create a method which returns the distance angle in degrees.

private float GetDistanceAngle(Transform transform, Vector2 screenMousePos)
{
    Vector2 worldMousePos = _mainCamera.ScreenToWorldPoint(screenMousePos);
    Vector2 offset = worldMousePos - (Vector2)transform.position;

    return Mathf.Atan2(offset.y, offset.x) * Mathf.Rad2Deg;
}

We rotate the weapon just around the Z axis, so we have to assign just it. This isn't possible setting transform.rotation.z directly, unless you know how to translate it (which you don't really need to know).

We use Quaternion.Euler method with Vector3.forward multiplied by the angle, which is the same as new Vector3(0f, 0f, angle), but more readable, as for me. Let's create a method out of it.

private void RotateTransform(Transform transform, float angle) =>
    transform.rotation = Quaternion.Euler(Vector3.forward * angle);

We can clamp the angle we get between the mininum and maximum values serialized in the Inspector as weaponRotMin and weaponRotMax.

public float weaponRotMin = -90f;
public float weaponRotMax = 90f;
float angle = Mathf.Clamp(GetDistanceAngle(weaponHandle, mousePos), weaponRotMin, weaponRotMax);

And so our current logic, which rotates the weapon in the direction of the mouse, looks like this.

private void Update()
{
    Vector2 mousePos = Input.mousePosition;

    if (mousePos != _prevMousePos)
    {
        float angle = Mathf.Clamp(GetDistanceAngle(weaponHandle, mousePos), weaponRotMin, weaponRotMax);

        RotateTransform(weaponHandle, angle);

        _prevMousePos = mousePos;
    }
}

private void RotateTransform(Transform transform, float angle) =>
    transform.rotation = Quaternion.Euler(Vector3.forward * angle);

private float GetDistanceAngle(Transform transform, Vector2 screenMousePos)
{
    Vector2 worldMousePos = _mainCamera.ScreenToWorldPoint(screenMousePos);
    Vector2 offset = worldMousePos - (Vector2)transform.position;

    return Mathf.Atan2(offset.y, offset.x) * Mathf.Rad2Deg;
}

3. Shoot the bullets from the weapon

3.1 Instantiate a bullet

Shoot the bullets from the weapon

We have to shoot, which means spawning the bullet, when the player presses the left mouse button.

if (Input.GetMouseButtonDown(0))
    // ...

This can be also implemented with other keys like Space, for instance.

if (Input.GetKeyDown(KeyCode.Space))
    // ...

We have to serialize the bullet and bullet holder, where the bullets are spawned, in the Inspector.

[Header("Bullet")]
[SerializeField] private Transform bullet;
[SerializeField] private Transform bulletHolder;

We spawn the bullet prefab using Instantiate method, the 2nd parameter is the parent the bullet is spawned in.

Transform newBullet = Instantiate(bullet, bulletHolder);

Then we have to get an offset, as shown on the image above.

We get the angle, which is the Z position of the bullet and translate it from degrees to radians, as we'll have to pass it into sin and cos.

Note that the it's not rotation.z, for the reason I've mentioned above, in the 1st header.

float angle = weaponHandle.eulerAngles.z * Mathf.Deg2Rad;

The diagonal, named PC on the image, is the half of the weapon and bullet lossyScales, which, unlike the localScale, is in global coordinates.

float diagonal = (weapon.lossyScale.x + bullet.lossyScale.x) * .5f;

Now, according to the cosine and sine ratio, out formulas look like this.

cosine formula

sine formula

And as we multiply them both by the diagonal, our offset is gotten this way.

Vector2 offset = new Vector2(Mathf.Cos(angle), Mathf.Sin(angle)) * diagonal

In order to get position of the instantiated bullet, we have to add the offset to the position of the weapon, as shown on the image.

Let us create a method for the offset.

private Vector2 GetBulletOffset(Transform bullet, float direction)
{
    float angle = direction * Mathf.Deg2Rad;

    float diagonal = (weapon.lossyScale.x + bullet.lossyScale.x) * .5f;

    return new Vector2(Mathf.Cos(angle), Mathf.Sin(angle)) * diagonal;
}

And now assign the new position of the bullet to the weapon's position added to the method's result.

Vector2 offset = GetBulletOffset(newBullet, direction);

newBullet.position = (Vector2)weapon.position + offset;

Now, in order to make a bullet that's not a perfect circle be shot correctly, we have to rotate it as shown on the image.

The rotation is the same as that of the weapon, and we can use previously created RotateTransform method.

RotateTransform(newBullet, weaponHandle.eulerAngles.z);

Let's create a full method of out it, which takes the rotation of the weapon as the parameter.

private void Shoot(float direction)
{
    Transform newBullet = Instantiate(bullet, bulletHolder);

    Vector2 offset = GetBulletOffset(newBullet, direction);

    newBullet.position = (Vector2)weapon.position + offset;
    RotateTransform(newBullet, direction);
}

And now the full bullet spawn logic looks like this.

private void Update()
{
    if (Input.GetMouseButtonDown(0))
        Shoot(weaponHandle.eulerAngles.z);
}

private void Shoot(float direction)
{
    Transform newBullet = Instantiate(bullet, bulletHolder);

    Vector2 offset = GetBulletOffset(newBullet, direction);

    newBullet.position = (Vector2)weapon.position + offset;
    RotateTransform(newBullet, direction);

    AddRecoilForce(offset);
}

private Vector2 GetBulletOffset(Transform bullet, float direction)
{
    float angle = direction * Mathf.Deg2Rad;

    float diagonal = (weapon.lossyScale.x + bullet.lossyScale.x) * .5f;

    return new Vector2(Mathf.Cos(angle), Mathf.Sin(angle)) * diagonal;
}

3.2 Make the bullet fly

I assume, you already have a bullet GameObject and there's nothing hard in its creation. Just make sure not to assign a Rigidbody2D with the gravity enabled.

We have to create a script for the bullet, which can be called BulletController.

Let's assign its speed in the Inspector.

public float speed = 10f;

Now, there are lots of ways to make it fly, so let's just use transform.Translate method in Update.

The bullet will fly e.g. to the right, which we multiply with the speed and Time.deltaTime, to make the bullet fly with the same speed, regardless of the frames per second.

private void Update()
{
    transform.Translate(speed * Time.deltaTime * Vector2.right);
}

It doesn't really matter, but we can add a lifetime field to destroy the bullet after the certain amount of seconds, but I am sure you can implement the desired logic yourself.

With the added BulletController script, the bullet flies as soon as it is spawned in the PlayerController script. So an example of the bullet script might look like this.

public class BulletController : MonoBehaviour
{
    public float speed = 10f;
    public float lifetime = 3f;

    private void Start() => StartCoroutine(Destroy());

    private void Update()
    {
        transform.Translate(speed * Time.deltaTime * Vector2.right);
    }

    private IEnumerator Destroy()
    {
        yield return new WaitForSeconds(lifetime);

        Destroy(gameObject);
    }
}

4. Add a recoil force from the shot

Add a recoil force from the shot

The recoil force usually happens in the opposite direction of the shot.

The force is usually applied with Rigidbody2D.AddForce(Vector2 force) method, so we have to assign the Rigidbody2D in the Start method first.

private Rigidbody2D _rigidbody;
private void Start()
{
    _rigidbody = GetComponent<Rigidbody2D>();
}
  • If we have an angle of 45 degrees, both x and y sides of the triangle are equal, which should give us a Vector of (.5, .5). This way, the force applied to the x and y axes is the same.

  • If we have an angle of 90 degrees, which triangle is basically a straight line (although it's not even a triangle), the Vector of (0, 1) is returned, giving us the force fully on the y axis, which makes the player jump upward (downward, in our case, as the direction is opposite)

Now, I have great news for you. We have already calculated the direction of the force with this line in the Shoot method.

Vector2 offset = GetBulletOffset(newBullet, direction);

Although there's a problem: the offset may be both:

  • (10, 20), with a ratio of 10 / 20 = .5
  • (100, 200), with a ratio of 100 / 200 = .5

And even though the ratios are the same, the applied force will be 10 times greater in the second variant.

That's why we use Vector2.normalized, which returns the Vector with the same ratio, but the maximum value of 1 (meaning 100%).

So in both variants the same Vector of (.5, 1) is returned. This allows us to apply the same force to the player, regardless of the offset.

Let's serialize the recoilForce field in the Inspector, for us to easily adjust it.

public float recoilForce = 10f;

And create an AddRecoilForce method, which adds the normalized force multipled by recoilForce and 10, to not make recoilForce too big.

private void AddRecoilForce(Vector2 direction) =>
    _rigidbody.AddForce(10f * recoilForce * -direction.normalized);

The method should be called directly in Shoot method with the previously assigned offset parameter, so it'll look like this.

private void Shoot(float direction)
{
    Transform newBullet = Instantiate(bullet, bulletHolder);

    Vector2 offset = GetBulletOffset(newBullet, direction);

    newBullet.position = (Vector2)weapon.position + offset;
    RotateTransform(newBullet, direction);

    AddRecoilForce(offset);
}

private void AddRecoilForce(Vector2 direction) =>
    _rigidbody.AddForce(10f * recoilForce * -direction.normalized);

Full code

Now, the full code, with some regions for readability, looks like this.

The PlayerController is assigned to the Player, and BulletController to the Bullet prefab.

using UnityEngine;

public class PlayerController : MonoBehaviour
{
    #region Fields

    [Header("Values")]
    public float recoilForce = 10f;

    public float weaponRotMin = -90f;
    public float weaponRotMax = 90f;


    [Header("Body")]
    [SerializeField] private Transform weaponHandle;
    [SerializeField] private Transform weapon;

    private Camera _mainCamera;
    private Rigidbody2D _rigidbody;

    private Vector2 _prevMousePos;


    [Header("Bullet")]
    [SerializeField] private Transform bullet;
    [SerializeField] private Transform bulletHolder;

    #endregion

    private void Start()
    {
        _mainCamera = Camera.main;
        _rigidbody = GetComponent<Rigidbody2D>();
    }

    private void Update()
    {
        #region Aim

        Vector2 mousePos = Input.mousePosition;

        if (mousePos != _prevMousePos)
        {
            float angle = Mathf.Clamp(GetDistanceAngle(weaponHandle, mousePos), weaponRotMin, weaponRotMax);

            RotateTransform(weaponHandle, angle);

            _prevMousePos = mousePos;
        }

        #endregion

        #region Shoot

        if (Input.GetMouseButtonDown(0))
            Shoot(weaponHandle.eulerAngles.z);

        #endregion
    }

    /// <summary>
    /// Rotates the transform around its Z axis
    /// </summary>
    private void RotateTransform(Transform transform, float angle) =>
        transform.rotation = Quaternion.Euler(Vector3.forward * angle);

    /// <returns>
    /// The angle between the transform and mouse positions
    /// </returns>
    private float GetDistanceAngle(Transform transform, Vector2 screenMousePos)
    {
        Vector2 worldMousePos = _mainCamera.ScreenToWorldPoint(screenMousePos);
        Vector2 offset = worldMousePos - (Vector2)transform.position;

        return Mathf.Atan2(offset.y, offset.x) * Mathf.Rad2Deg;
    }

    /// <summary>
    /// Spawns a bullet and applies the recoil force to the player
    /// </summary>
    private void Shoot(float direction)
    {
        Transform newBullet = Instantiate(bullet, bulletHolder);

        Vector2 offset = GetBulletOffset(newBullet, direction);

        newBullet.position = (Vector2)weapon.position + offset;
        RotateTransform(newBullet, direction);

        AddRecoilForce(offset);
    }

    /// <returns>
    /// The offset between the weapon and bullet positions
    /// </returns>
    private Vector2 GetBulletOffset(Transform bullet, float direction)
    {
        float angle = direction * Mathf.Deg2Rad;

        float diagonal = (weapon.lossyScale.x + bullet.lossyScale.x) * .5f;

        return new Vector2(Mathf.Cos(angle), Mathf.Sin(angle)) * diagonal;
    }

    /// <summary>
    /// Adds a recoil force in the opposite direction
    /// </summary>
    private void AddRecoilForce(Vector2 direction) =>
        _rigidbody.AddForce(10f * recoilForce * -direction.normalized);
}
using UnityEngine;
using System.Collections;

public class BulletController : MonoBehaviour
{
    public float speed = 10f;
    public float lifetime = 3f;

    private void Start() => StartCoroutine(Destroy());

    private void Update()
    {
        transform.Translate(speed * Time.deltaTime * Vector2.right);
    }

    /// <summary>
    /// Destroys the bullet after lifetime seconds
    /// </summary>
    private IEnumerator Destroy()
    {
        yield return new WaitForSeconds(lifetime);

        Destroy(gameObject);
    }
}

Upvotes: 0

Related Questions