Topdown Tank Battle: Part 10

Tags: godot gamedev tutorial

In this tutorial series, we’ll walk through the steps of building a 2D top-down tank game using Godot 3.0. The goal of the series is to introduce you to Godot’s workflow and show you various techniques that you can apply to your own projects.

This is Part 10: Homing missiles

You can watch a video version of this lesson here:

Introduction

This time, we’re going to build on the existing bullet object to create a heat- seeking missile. We’ll also adjust the shooting code to allow for multiple shots in a spread.

Adjusting the Bullet script

We’re going to start by adding the homing functionality to the existing bullet script. First, we’ll adding some new variables:

export (float) var steer_force = 0

var acceleration = Vector2()
var target = null

steer_force represents the missile’s agility - how quickly it can turn and chase its target. A value of 0 will be a regular bullet that travels in a straight line.

When the missile spawns, we can pass it a target, but we want to be able to ignore that when spawning “dumb” bullets, so adjust the start() function like so:

func start(_position, _direction, _target=null):
    position = _position
    rotation = _direction.angle()
    velocity = _direction * speed
    target = _target

Next, in the _process() function, we need to apply a seeking behavior if there is a target assigned:

func _process(delta):
    if target:
        acceleration += seek()
        velocity += acceleration * delta
        velocity = velocity.clamped(speed)
        rotation = velocity.angle()
    position += velocity * delta

We’ll find our needed acceleration via the seek() function (more about that in a moment), and apply that to the current velocity. We also need to clamp the velocity so that when heading straight for the target, the missile doesn’t continue to accelerate above the maxiumum speed. Finally, we set the rotation so that the missile will point in the direction of motion.

Seeking behavior

“Steering behaviors” is the collective name for a number of algorithms that produce movement such as seeking, following, flocking, etc. If you’re not familiar with the topic, I highly recommend reading The Nature of Code, which has a fantastic chapter on the subject, along with interactive demos.

For our missile, we will be using the “seek” behavior:

func seek():
    var desired = (target.position - position).normalized() * speed
    var steer = (desired - velocity).normalized() * steer_force
    return steer

This works by first finding the desired movement vector, which is straight towards the target at maximum speed. However, to avoid “instant” turning, we then calculate the steer vector by finding the difference between the current velocity and the desired one. The “amount” we move our velocity (i.e. accelerate) is that difference scaled by the steer_force. The result: the bigger the steer_force, the faster the object turns to move towards its target.

Missile scene

The missile scene is an inherited scene created from Bullet.tscn. I’ve chosen the following image from the spritesheet for the missile:

alt

Set its speed to 400 and steer_force to 25 in the Inspector and save the scene. Drop it in to the Bullet property of one of the enemy tanks in your map scene.

Launching missiles

If you ran it now, your missiles would still travel in a straight line, because they’re not getting the target value from the enemy. Update the shooting line in EnemyTank.gd to pass this data (the enemy already has the player set as its target):

shoot(target)

Now update the base Tank.gd script to pass this data along to the missile when it spawns:

func shoot(target=null):
    if can_shoot:
        can_shoot = false
        $GunTimer.start()
        var dir = Vector2(1, 0).rotated($Turret.global_rotation)
        emit_signal('shoot', Bullet, $Turret/Muzzle.global_position, dir, target)
        $AnimationPlayer.play('muzzle_flash')

Finally, the Map.gd script, when it receives the signal, needs to pass the target to the missile:

func _on_Tank_shoot(bullet, _position, _direction, _target=null):
    var b = bullet.instance()
    add_child(b)
    b.start(_position, _direction, _target)

Try running the scene and testing that the missiles seek the player. Try changing the steer_force and speed (and the missile’s lifetime) properties to see how they affect the missile’s behavior.

Missile trail

To make the missile look more appealing, we’re going to add a smoke trail. Add a Particles2D node to the missile scene. Particles nodes have a large number of properties, so I’ll list the adjustments in the table below:

Property Value
Amount 35
Lifetime 0.4
Visibility/Show Behind Parent On
RayCastUp (0, -32)

For the texture, I found the following smoke animation on Open Game Art: alt

Add this to the Texture property and set the HFrames to 24.

Now add a new ParticlesMaterial in the Process Material property and set its values like so:

Property Value
Gravity (0, 0, 0)
Initial Velocity 0
Animation/Speed 1

Finally, in the Scale section, add a new Scale Curve and set its endpoints like the picture below:

alt

This will make the smoke grow gradually larger as it dissipates.

The missiles should now have a pleasing smoke trail:

alt

Multi-shot

One more improvement we can make to shooting: multi-shot. This will allow us to have an enemy (or player) shoot multiple shots at once in a spread.

Start by adding two more new properties to Tank.gd:

export (int) var gun_shots = 1 # number of projectiles per shot
export (float, 0, 1.5) var gun_spread = 0.2 # angle between them

Once more, we need to update the shoot() function:

func shoot(num, spread, target=null):
    if can_shoot:
        can_shoot = false
        $GunTimer.start()
        var dir = Vector2(1, 0).rotated($Turret.global_rotation)
        if num > 1:
            for i in range(num):
                var a = -spread + i * (2*spread)/(num-1)
                emit_signal('shoot', Bullet, $Turret/Muzzle.global_position, dir.rotated(a), target)
        else:
            emit_signal('shoot', Bullet, $Turret/Muzzle.global_position, dir, target)
        $AnimationPlayer.play('muzzle_flash')

If the number of shots is 1, we can just do as we already are doing. However, if the number is greater, we need a counting loop to generate each one and set its angle based on the spread. Calculating that angle (a) is complicated by the fact that num may be even or odd. If it’s odd, we want the center shot to go forward, and the others to its left and right. Howevef, if num is even, there is no center bullet, so we need an equal number to the left and right. The a formula will calculate that for us.

Now our player and enemy tanks need to pass these properties when they call shoot:

EnemyTank.gd:

if target_dir.dot(current_dir) > 0.9:
    shoot(gun_shots, gun_spread, target)

Player.gd:

if Input.is_action_just_pressed('click'):
    shoot(gun_shots, gun_spread)

alt

Conclusion

That completes Part 10 of this series. For coming features and ideas, I’ve created a project tracker, which you can see here:

Topdown Tanks Project Tracker

Please comment below with your questions and suggestions.

Download the code for this part

Comments