Crumblebit
Making 2D Guns Fun!

Hi! I'm creating a top-down shooter in Godot Game Engine and I want to share my solution for a 2D weapon scene. There are many tutorials on how to create a basic gun for shooting bullets. In this article I will explain how just a few lines of code can make a simple gun do a lot more! Note that I have no experience with real guns and I am making this for fun, so some terms might be wrong but I try to make it easy to understand. The current gun script looks like this. We have shoot, reload and spawn_bullet functions controlled by a player script and a timer:

    
    extends Node2D

    export var bullet_scene:PackedScene

    export var shoot_rate:float = 0.3
    export var reload_time:float = 0.5
    export var magazine_size:int = 10

    var magazine_ammo:int = 10
    var reloading:bool = false

    onready var muzzle = get_node("Muzzle")
    onready var timer = get_node("Timer")
    onready var bullets = get_node("Bullets")

    func spawn_bullet(dir:Vector2, pos:Vector2):
        magazine_ammo -= 1
        var bullet = bullet_scene.instance()
        bullet.position = pos
        bullet.rotation = dir.angle()
        bullets.add_child(bullet)

    func shoot():
        if magazine_ammo && timer.time_left == 0:
            var dir = (muzzle.global_position - global_position).normalized()
            spawn_bullet(dir, muzzle.global_position)
            timer.start(shoot_rate)

    func reload():
        timer.start(reload_time)
        reloading = true

    func _on_Timer_timeout():
        if magazine_ammo == 0:
            reload()

        if reloading:
            magazine_ammo = magazine_size
            reloading = false
    

It works well for shooting bullets in one direction. But we want more. We add more parameters.

    
    extends Node2D

    export var shoot_rate:float = 0.3
    export var reload_time:float = 2
    export var magazine_size:int = 5
    export var shoot_angle:int = 90
    export var bullet_scene:PackedScene

    var magazine_ammo:int = magazine_size
    var angle_start = deg2rad(shoot_angle/2)
    var angle_between_bullets = 0
    var reloading = false

    onready var muzzle = get_node("Muzzle")
    onready var bullets = get_node("Bullets")
    onready var timer = get_node("Timer")
    

Right now we have a shoot function that spawns bullets when magazine is not empty and the reload timer is not running.

    
    func shoot():
        if magazine_ammo && timer.time_left == 0:
            var dir = (muzzle.global_position - global_position).normalized()
            spawn_bullet(dir, muzzle.global_position)
            timer.start(shoot_rate)
    

We want to shoot multiple bullets at different angles. We add a starting angle. It is half the shooting angle. If we choose 90 degrees shooting angle, we make a direction vector to muzzle and rotate it by -45 degrees. This makes the direction fall to the left, centering the shooting angle to the muzzle direction.

Next, we calculate a small angle towards the other side (plus 45 degrees). It is a little more complicated. If we shoot one bullet, we do nothing. Just shoot in the -45-degree angle. Otherwise, we take the shooting angle and divide it by the magazine size minus one. The minus one is necessary because we don't want to take the 0 into account.

We only need to recalculate these angles every time the shooting angle or magazine size is updated.

    
    func _ready():
        magazine_ammo = magazine_size
        adjust_angles()

    func adjust_angles():
        angle_start = -deg2rad(shoot_angle*0.5)
        angle_between_bullets = 0
        if magazine_size != 1:
            angle_between_bullets = deg2rad(shoot_angle) / (magazine_size-1)
    

Next step is updating the shoot function to use the new angles (angle_start and angle_between_bullets).

The starting angle is applied each shot.

The angle between bullets needs to be added a number of times depending on the amount of bullets left in the magazine. With a 90-dgree shooting angle, a (-45)-degree starting angle and five bullets the angles would be as follows for each bullet (in same order):

45, 22.5, 0, -22.5, -45

The angle between bullets is 90 / 4 = 22.5. As you can see the bullets spawn oposite to the starting angle. You may want to invert the magazine_ammo but it is easier to just multiply directly.

Once again the minus one is because we don't want the zero to be counted.

    
    func shoot():
        if magazine_ammo != 0 && timer.time_left == 0:
            var dir = (muzzle.global_position - global_position).normalized()

            dir = dir.rotated(angle_between_bullets*(magazine_ammo-1))
            dir = dir.rotated(angle_start)
            
            spawn_bullet(dir, muzzle.global_position)

            ...
    
Multiple Bullets per Shot

To shoot multiple bullets in just one shot we add another parameter to the script.

    
    extends Node2D

    export var shoot_rate:float = 0.3
    export var reload_time:float = 2
    var bullets_per_shot:int = 2
    export var magazine_size:int = 5
    export var shoot_angle:int = 90
    
    ...
    

Then we just add a for loop and decrease the angle each shot.

    
    for bullet in bullets_per_shot:
        if magazine_ammo:
            spawn_bullet(dir, muzzle.global_position)
            dir = dir.rotated(-angle_between_bullets)
    
Scatter

Adding bullet scatter is easy.

    
    extends Node2D
    
    export var shoot_rate:float = 0.3
    export var reload_time:float = 2
    var bullets_per_shot:int = 2
    export var magazine_size:int = 5
    export var shoot_angle:int = 90
    export var scatter_angle:int = 0

    ...

    func spawn_bullet(dir:Vector2, pos:Vector2):
        magazine_ammo -= 1
        var bullet = bullet_scene.instance()
        bullet.position = pos
        bullet.rotation = dir.angle() + deg2rad(rand_range(-scatter_angle, scatter_angle))*0.5
        bullets.add_child(bullet)
    
Final Script

Now we have a gun that can shoot bullets in all directions with reload, scatter and more! The nice thing about calculating the angles is that we can adjust bullet amount and angles and everything with just variables.

    
    extends Node2D

    export var shoot_rate:float = 0.3
    export var reload_time:float = 2
    export var bullets_per_shot:int = 2
    export var magazine_size:int = 5
    export var shoot_angle:int = 90
    export var scatter_angle:int = 0
    export var bullet_scene:PackedScene

    var magazine_ammo:int = magazine_size
    var angle_start = -deg2rad(shoot_angle/2)
    var angle_between_bullets = 0
    var reloading = false

    onready var muzzle = get_node("Muzzle")
    onready var bullets = get_node("Bullets")
    onready var timer = get_node("Timer")

    func _ready():
        magazine_ammo = magazine_size
        adjust_angles()

    func spawn_bullet(dir:Vector2, pos:Vector2):
        magazine_ammo -= 1
        var bullet = bullet_scene.instance()
        bullet.position = pos
        bullet.rotation = dir.angle() + deg2rad(rand_range(-scatter_angle, scatter_angle))*0.5
        bullets.add_child(bullet)

    func shoot():
        if magazine_ammo != 0 && timer.time_left == 0:
            var dir = (muzzle.global_position - global_position).normalized()

            dir = dir.rotated(angle_between_bullets*(magazine_ammo-1))
            dir = dir.rotated(angle_start)

            for bullet in bullets_per_shot:
                if magazine_ammo:
                    spawn_bullet(dir, muzzle.global_position)
                    dir = dir.rotated(-angle_between_bullets)

            timer.start(shoot_rate)

    func reload():
        timer.start(reload_time)
        reloading = true

    func _on_Timer_timeout():
        if magazine_ammo == 0:
            reload()

        if reloading:
            magazine_ammo = magazine_size
            reloading = false

    func adjust_angles():
        angle_start = -deg2rad(shoot_angle*0.5)
        angle_between_bullets = 0
        if magazine_size != 1:
            angle_between_bullets = deg2rad(shoot_angle) / (magazine_size-1)
    
Results

These are the results after replacing the timer with animation node and adding some sprites and enemies and steering.