Firing a continuous laser

For this bonus lesson, we create a single, continuous laser beam that causes damage over time so long as it’s impacting a target. This is an excellent example of a persistent projectile - a projectile that doesn’t die. The emitter doesn’t even spawn it; it lives with it.

Tracing the path the laser will take using an invisible tracer

The laser begins with a tracer: an invisible projectile that travels through space ‘tracing’ a path that the laser itself follows. This is a beam traveling at light speed, so it should be near-instant (but not too instant, as we’re in space dealing with cosmic sizes!). So the tracer will travel from start to finish in one frame, and we make a Line2D draw its points based on the locations it visited.

The laser itself is a Line2D, but the particles come from GDQuest’s Godot 2D Visual Effects repo.

src/Projectile/Laser/LaserTracer.gd

extends Projectile

# Constant that indicates what kind of update step the laser should take. 1/60 simulates a 60 FPS update loop which is what Godot's physics engine uses by default.
const TIME_STEP := 1.0 / 60.0

# The collision normal when the tracer hits something. We use that to orient the impact particles so they match with how the laser is hitting the target.
var collision_normal := Vector2.ZERO

# The impact particles to emit whenever the laser is hitting something.
onready var impact_particles := $ImpactParticles

Moving the tracer from start to finish in a single frame

We start at 0,0, then use move_and_collide to move the invisible tracer along in a loop that lasts for the lifetime of the tracer. If it hits something, we stop moving the projectile but finish updating the tracer’s motions. This is in case they have any time-based state we want to make sure stays consistent. If it misses, add its last position relative to the world to an array of positions. Rinse and repeat until we reach the lifetime of the laser. We feed this array to a Line2D.

To make sure that the laser seems to extend, we pass the lifetime_actual parameter to the function. While we update the motions with the full lifetime so the results are consistent, we update the position of the tracer according to its ‘actual’ lifetime. When it’s small, the laser isn’t fully traced out, but when it’s equal to lifetime, then we return every point.

Note the calls to _impact and _miss in their appropriate locations so we can cause damage or trigger events.

Another important note is that all the positions are in global coordinates. When we make the Line2D and the particles, we make sure they are set as top-level - meaning they don’t inherit the transform of their parent. This makes the math a lot easier without having to translate relative to the ship’s position or orientation.

func trace_path(lifetime_actual: float) -> Array:

    # Restart position at 0 relative to parent
    position = Vector2.ZERO

    # Add starting position to array
    var positions := [global_position]

    # Start at 0 and no collision
    var current_time := 0.0
    var collided := false

    while current_time < lifetime:
        current_time += TIME_STEP

        # Update motions. We make sure this is updated so any state changes the motions undergo is consistent every frame
        var planned_movement := _update_movement(TIME_STEP)

        # Move the tracer. If it hits, don't move it more but record its collision. If it misses, record its position.
        # Also stop if we go over the implicit lifetime, otherwise even a short laser could hit a far away target.
        if not collided and current_time < lifetime_actual:
            var collision := move_and_collide(planned_movement)

            if not collision:
                positions.append(global_position)
            else:
                positions.append(collision.position)
                emit_signal("collided", collision.collider, collision.position)

                collision_normal = collision.normal
                _impact()

                collided = true

    if not collided:
        _miss()
        # Return a percentage of positions equal to the implicit lifetime compared to the full lifetime.
        return positions.slice(0, int(lifetime_actual / lifetime * positions.size()))
    else:
        return positions

Emitting particles on impact

Impacting and missing with the laser means turning the particles on or off, and positioning them accordingly.

func _impact() -> void:
    impact_particles.global_position = global_position
    impact_particles.rotation = collision_normal.angle()
    impact_particles.emitting = true


func _miss() -> void:
    impact_particles.emitting = false

Building the tracer scene

The scene is just the tracer as a kinematic body 2D with the script and a circle collision shape. The particles have a built-in script:

extends Particles2D


func _ready() -> void:
    set_as_toplevel(true)

This ensures they won’t inherit the tracer’s position, rotation or scale from its parent.

Coding the laser emitter

The emitter needs to do a few things: it needs to make the tracer update its path, control the appearance and size of the laser, and control how it shoots and causes damage.

src/Projectile/Laser/LaserEmitter.gd

extends ProjectileEmitter


# The amount of times, per second, that the laser is allowed to cause damage. Has a technical max equal to the framerate.
export var collisions_per_second := 4

# Whether the laser is currently being fired or not.
var is_firing := false


# The current lifetime compared to the actual projectile lifetime. It controls the length and width of the laser up from thin to full life.
var current_lifetime := 0.0

# The tracer projectile.
onready var tracer := $LaserTracer

# The Line2D that will draw the laser.
onready var laser_line := $Line2D

# The timer that controls collisions per second.
onready var timer := $Timer

# Particles to emanate when firing the laser.
onready var casting_particles := $CastingParticles

Resetting and updating the tracer while firing

When we first fire the laser, we reset the lifetime in the _do_fire function. We also show the tracer because hidden kinematic objects don’t update and thus don’t move and collide with objects. So the tracer starts hidden until we need it. We also begin emitting the casting particles from the ship’s cannon.

Finally, we call the setup function. This resets the tracer’s position so it matches the emitter’s position in space, and makes sure it’s pointing in the same direction as the emitter.

func _do_fire(_direction: Vector2, _motions: Array, _lifetime: float) -> void:
    if not is_firing:
        is_firing = true
        current_lifetime = 0.0
        tracer.show()
        casting_particles.emitting = true
    tracer.setup(global_position, Vector2.UP.rotated(global_rotation), _motions, _lifetime)

Tracing the path once per frame while firing

When we hold down the button, we call the fire function and increment the current lifetime. This lifetime dictates the laser width and is what trace_path uses for its actual lifetime to limit the length of the laser. The points that the tracer create are then placed inside the Line2D’s points array to draw the final laser.

func _physics_process(_delta: float) -> void:
    if Input.is_action_pressed("fire"):
        fire()

        current_lifetime = min(
            current_lifetime + _delta * projectile_lifetime * 8, projectile_lifetime
        )

    laser_line.width = current_lifetime / projectile_lifetime * 10

    if firing:
        var points: Array = tracer.trace_path(current_lifetime)
        laser_line.points = points

Shrinking the laser over time when done firing

When we let go of the button, we need to stop firing and shrink the laser down over time. We can do that with a yielding for loop and shrink it by 10% each time with a simple division. Once the loop completes, we clear the array, hide the tracer, reset its position, and call _miss on the tracer so it stops emitting particles.

func _unhandled_input(event: InputEvent) -> void:
    if event.is_action_released("fire"):
        firing = false

        var points: Array = laser_line.points
        for i in range(1, 10):
            current_lifetime = float(projectile_lifetime / i)
            yield(get_tree(), "physics_frame")

        laser_line.points = []
        tracer.hide()
        tracer.position = Vector2.ZERO
        tracer._miss()
        casting_particles.emitting = false

Controlling how often the laser causes damage during impact

We have to override the _on_projectile_collided method since we want to control how often impacts are allowed to occur (so you only do 20 damage per second instead of 300!). To do that, we call the original ProjectileEmitter class’ implementation of the function using a One Shot timer. To call the extended class’ version of the function, we use the . operator, which refers to super in other languages.

func _on_projectile_collided(target: Node, _hit_location: Vector2) -> void:
    if timer.is_stopped():
        ._on_projectile_collided(target, _hit_location)
        timer.start(1.0 / float(collisions_per_second))

Building the laser emitter scene and attaching it as the emitter

Now we can build the emitter. This one goes into the Projectile resources next to BasicEmitter.tscn. The emitter scene has the hidden tracer (with its collided signal connected to the emitter’s _on_projectile_collided function), a Line2D that has a built-in set_as_toplevel(true) script, the collisions-per-second timer, and the casting particles.

Projectile/LaserEmitter.tscn