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.
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
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
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
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.
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
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)
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
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
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))
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