Reputation: 41
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
Reputation: 351
Alright, so you want to:
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.
I assume you have already implemented this logic, so that's how my player looks like
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 wantThe α
(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
.
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;
}
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.
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;
}
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);
}
}
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);
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