In this lesson, we create two new motions: a sine wave so that a projectile can move side to side as it travels, and a homing motion that finds an enemy and changes direction towards it.
A sine wave is a curve that keeps moving from 1 to -1. It’s called the sine wave because it uses the sin
math function. We define a wave by its amplitude (how high the peaks go), its frequency (how often a peak happens), and the speed with which the wave travels. In our case, the speed is 1 second, and the weapon designer controls the amplitude and frequency.
We use PI
in the math function to wrap our frequency and elapsed time to convert the elapsed time into an angle around a circle.
This is a motion that maintains state in the form of time, which is why emitters like the laser must update the motions to keep getting consistent results!
class_name SineMotion extends ProjectileMotion # How far the projectile travels between the two points of origins export var amplitude := 100.0 # How many times per second the projectile will travel from the point of origin and the next export var frequency := 2 # The amount of time passed since we created the motion. var elapsed_time := 0.0 func _update_movement(direction: Vector2, delta: float) -> Vector2: elapsed_time += delta var wobble_amount := amplitude * sin(elapsed_time * frequency * PI) * frequency # The wobble direction is perpendicular to the projectile's intended direction path. # I.E. going straight up means wobbling side to side. var travel_direction := Vector2(-direction.y, direction.x) return travel_direction * wobble_amount * delta
The homing motion uses a circle and detects physics bodies. When it finds one, it starts to turn towards the nearest one not by moving, but by changing direction.
The traditional way of doing this is an Area2D and get_overlapping_bodies
. We could attach an Area2D to the projectile and listen for it. But the issue comes with persistent projectiles like the laser or hitscan sniper weapons which move from start to finish all in one frame. The physics server only updates once per frame. As a remedy, we get the direct space state of the physics engine, and we check for overlap ourselves.
class_name HomingMotion extends ProjectileMotion # The radius of the circle shape we will check collisions with export var homing_radius := 60.0 # How strong the change in direction should be. When it's low, it can lead to an orbit-like movement, while very strong will lead to the object making a straight sharp corner bee-line. export (float, 0.0, 0.5, 0.025) var homing_strength := 0.067 # The collision layer to determine what kind of objects the homing will consider. Wouldn't want the homing missile to turn right back around and hit the player. export (int, LAYERS_2D_PHYSICS) var collision_mask: int # The circle shape whose radius we set to represent the area of collision. var _circle: CircleShape2D # The direct space state. This is an object that represents the ever-changing state of the 2D physics, and it has some helper functions to get information from it. var _space: Physics2DDirectSpaceState # A shape query object is an object we can configure that tells the space state how to get the information we're after. Position, shape, collision layer, etc. var _query: Physics2DShapeQueryParameters
In a setup function, we can fill the properties with the data we’re after. We create a new circle and set its radius. We then use the projectile as a way to grab the 2D World and grab its direct space state. We also configure the query with our parameters.
func _setup_shape() -> void: _circle = CircleShape2D.new() _circle.radius = homing_radius _space = projectile.get_world_2d().direct_space_state _query = Physics2DShapeQueryParameters.new() _query.set_shape(_circle) _query.collision_layer = collision_mask
In a _find_target
function, we can use the query and space state to test for targets.
We start by positioning the query with the projectile’s position and pass it into a call to intersect_shape
. The second parameter is an optional max number of hits. We keep it low because we’re calling this every physics frame and we don’t want to overwhelm the system with too much physics math.
If we don’t find any intersections, then there’s no target, and we return null
. Otherwise, we iterate over the array of intersections and use a for
loop to find the nearest collider then return the lucky winner.
func _find_target() -> Node2D: _query.transform = projectile.global_transform var intersections := _space.intersect_shape(_query, 2) if intersections.size() > 0: var min_distance: float = INF var min_target: PhysicsBody2D for intersection in intersections: var distance: float = intersection.collider.global_position.distance_to(projectile.global_position) if distance < min_distance: min_distance = distance min_target = intersection.collider return min_target return null
We can then call these functions inside of _update_movement
. We first call _setup_shape
if we don’t have a query yet, and then _find_target
.
If we do have a match, then the intended direction is a vector that points towards the target. If we add the product of this vector and the homing strength to the current direction and normalize the result, we get a direction that’s biased towards the new one.
Because the homing motion only changes direction, we return no actual movement vector.
func _update_movement(_direction: Vector2, _delta: float) -> Vector2: if not _query: _setup_shape() var target := _find_target() if target: var intended_direction := (target.global_position - projectile.global_position).normalized() projectile.direction = (_direction + intended_direction * homing_strength).normalized() return Vector2.ZERO