What we need:

A versatile shooting mechanic that we can re-use for both our player and enemies. The reason for this is that later when we add status effects, we want to avoid code duplication so we use the same component across both.

The main Shooting component has two simple methods for now:

public void Shoot(GameObject bullet, float direction)
{
    Instantiate(bullet, transform.position, Quaternion.Euler(0f, 0f, direction));
}

public void ShootMultiple(GameObject bullet, float direction, int count, float spread)
{
    float startAngle = direction - (spread * 0.5f);
    for (int i = 0; i < count; i++)
    {
        Shoot(bullet, startAngle + i * (spread / (count - 1)));
    }
}

In the future, I’ll also add convenience methods like ShootRing() and add optional Vector3 offset parameters to the methods which will default to Vector3.zero if not specified.

The above interface is nice and simple to use from places like an enemy AI component or a player shooting component.

We use the On[ACTION](InputValue input) method that gets called by unity every time the player presses an input, in this case the Shoot input, mapped by default to the primary mouse button.

public void OnShoot(InputValue input)
{
    if (input.Get<float>() == 1f)
    {
        StartCoroutine(Shoot());
    }
    else
    {
        StopAllCoroutines();
    }
}

This method also gets called when the player lets go of the mouse button, and we check whether it has just been pressed or released by checking the return value of Get against 1f.

IEnumerator Shoot()
{
    if (bullet == null) yield break;
    if (Time.time < lastShotTime + (1 / fireRate)) yield return new WaitForSeconds((1 / fireRate) + lastShotTime - Time.time);
    WaitForSeconds wait = new WaitForSeconds(1f / fireRate);
    while (true)
    {
        Ray ray = cam.ScreenPointToRay(Mouse.current.position.ReadValue());

        Plane plane = new Plane(Vector3.forward * -1, transform.position.z);
        plane.Raycast(ray, out float dist);
        Vector2 mouse = cam.ScreenToWorldPoint(new Vector3(Mouse.current.position.ReadValue().x, Mouse.current.position.ReadValue().y, dist));

        float dir = 180 + Mathf.Rad2Deg * Mathf.Atan2(transform.position.y - mouse.y, transform.position.x - mouse.x);
        shooting.Shoot(dir, bullet);
        lastShotTime = Time.time;

        yield return wait;
    }
}

This is the main Shoot() method. There’s quite a bit going on so I’ll break it down.

On the first line we simply check if the bullet field is assigned. It can be null when the player has no weapon equipped and for now, we’ll just break out of the coroutine.

The second line checks whether its been enough time since the last shot and if not, waits the correct amount before continuing. This can happen when the player is only meant to be able to shoot every 1 second, but by pressing shoot faster than that the player could shoot as often as they want.

The next line, we just simply cache a WaitForSeconds instance as it’s always the same duration, and returning a new instance causes 24 bytes of memory allocation each time.

Now we get into the main loop, where we first create a Ray at the mouse position. We set up a plane at the players z coordinate that is facing upwards in our coordinate system (-z).

We raycast the ray onto the plane, and get the distance from the camera to that point on the plane. This is needed as this distance varies based on where on the screen you click due to out angled camera setup. The higher on the screen you click, the longer that distance becomes.

Now we use the ScreenToWorldPoint() function on our camera to get the actual world position of the cursor.

Lastly, we shoot the bullet using Atan2() to get the angle from the player to that point and yield our cached WaitForSeconds