Modifying Projectiles through code

While ProjectileBlueprint2D customization is very extensive and complete, there are certain complex behaviors that can only be achieved through code. Fortunately, All Projectiles allows you to attach any custom behavior you want to your projectiles.

In short, your projectiles can store custom Callables to be called at specific times. Each Callable follows a define pattern and must take a set number of parameters and return a specific value.

See: set_on_move, set_on_start, set_on_collision and set_on_expired.


Custom Projectile movement

Continuing with our previous project, let's modify the trajectory of our projectiles.

Inside our Player's script, let's add a method that makes a projectile to always move up and set it to our projectile in use. I recommend you check out the Projectile2D set_on_move method to learn more.

extends Node2D

@onready var projectile_caller: ProjectileCaller2D = $ProjectileCaller2D


func _input(event):
	if event is InputEventMouseButton:
		if event.button_index == MOUSE_BUTTON_LEFT and event.pressed:
			projectile_caller.request_projectile(0, position, get_global_mouse_position())


# In this example we set the "custom_movement" function at our projectile with index (0).
func _ready() -> void:
	projectile_caller.get_projectile(0).set_on_move(custom_movement)


# In order to function, your movement method must follow this design pattern.
func custom_movement(proj: Projectile2D, _delta: float) -> Vector2:
	return Vector2.UP

And just like that, all your projectiles will always go upwards!

With this in mind, let's create a more complex behavior and make our projectile move in a wave-like pattern. By modifying our custom_movement method, we would get something like this:

func custom_movement(proj: Projectile2D, _delta: float) -> Vector2:
	var local_time: float = proj.resource.lifetime - proj.lifetime
	return proj.direction + (proj.direction.orthogonal() * cos(local_time * 20))

We can go further and parameterize our expression:

func custom_movement(proj: Projectile2D, _delta: float) -> Vector2:
	var local_time: float = proj.resource.lifetime - proj.lifetime
	var frecuency: float = 20
	var amplitude: float = 2

	return proj.direction + (proj.direction.orthogonal() * cos(local_time * frecuency)) * amplitude

So now we can incorporate these new variables in our ProjectileBlueprint2D.

func custom_movement(proj: Projectile2D, _delta: float) -> Vector2:
	var local_time: float = proj.individual_properties["LOCAL_TIME"] + _delta
	proj.individual_properties["LOCAL_TIME"] = local_time

	var frecuency: float = proj.global_properties["FRECUENCY"]
	var amplitude: float = proj.global_properties["AMPLITUDE"]

	return proj.direction + (proj.direction.orthogonal() * cos(local_time * frecuency)) * amplitude

This method ensures that all other parameters continue to work even if there is any custom movement.

If you set your projectiles to don't look at the direction they travel, they do.

If you set your projectiles to seek its targets, they do. All without the need of any extra code.

At the end of this exercise, your player script should look something like this:

extends Node2D

@onready var projectile_caller: ProjectileCaller2D = $ProjectileCaller2D


func _ready() -> void:
	projectile_caller.get_projectile(0).set_on_move(custom_movement)


func _input(event):
	if event is InputEventMouseButton:
		if event.button_index == MOUSE_BUTTON_LEFT and event.pressed:
			projectile_caller.request_projectile(0, position, get_global_mouse_position())


func custom_movement(proj: Projectile2D, _delta: float) -> Vector2:
	var local_time: float = proj.individual_properties["LOCAL_TIME"] + _delta
	proj.individual_properties["LOCAL_TIME"] = local_time

	var frecuency: float = proj.global_properties["FRECUENCY"]
	var amplitude: float = proj.global_properties["AMPLITUDE"]

	return proj.direction + (proj.direction.orthogonal() * cos(local_time * frecuency)) * amplitude

Custom Projectile collisions

Now, let's say I have a projectile with some pierce and I want it to be destroyed immediately after hitting a wall. Well, even if I set my walls on a different Collision Layer and modify my projectiles to interact with them.

Their collisions are still treated as the same and the projectile just goes through the walls.

What we need to achieve this behavior is some kind of custom collision. Fortunately, All Projectiles allows this.

So, just like our custom movement example, let's add a method that replaces the collision behavior of our projectiles inside our player script. Once again, you can learn more about its defined pattern by consulting the set_on_area_collision method

extends Node2D

@onready var projectile_caller: ProjectileCaller2D = $ProjectileCaller2D


func _input(event):
	if event is InputEventMouseButton:
		if event.button_index == MOUSE_BUTTON_LEFT and event.pressed:
			projectile_caller.request_projectile(0, position, get_global_mouse_position())


# In this example we set the "custom_collision" function at our projectile with index (0).
func _ready() -> void:
	projectile_caller.get_projectile(0).set_on_collision(custom_collision)


# In order to function, your collision method must follow this design pattern.
func custom_collision(proj: Projectile2D, area_rid: RID, area_node: Node2D, target_node: Node2D, area_shape_index: int, local_shape_index: int) -> void:
	pass

The first thing you will notice is that we have lost our collisions, like all of them.

Don't worry, we just need to add its default code to recover our collisions.

func custom_collision(proj: Projectile2D, area_rid: RID, area_node: Node2D, target_node: Node2D, area_shape_index: int, local_shape_index: int) -> void:
	if not proj.validate_collision(area_rid, area_node):
		return
	
	# -> Our custom code should go here. <-

	proj.on_pierced(area_rid)

Now we can check if our projectile collided with a wall and eliminate it ipso facto.

func custom_collision(proj: Projectile2D, area_rid: RID, area_node: Node2D, target_node: Node2D, area_shape_index: int, local_shape_index: int) -> void:
	if not proj.validate_collision(area_rid, area_node):
		return
	
	var wall_layer: int = proj.global_properties["WALL_LAYER"]
	if wall_layer & area_node.collision_layer != 0:
		proj.is_expired = true
		return

	proj.on_pierced(area_rid)

Remember to recall your on_hit_call method to restore your projectile original behavior.

func custom_collision(proj: Projectile2D, area_rid: RID, area_node: Node2D, target_node: Node2D, area_shape_index: int, local_shape_index: int) -> void:
	if not proj.validate_collision(area_rid, area_node):
		return
	
	var wall_layer: int = proj.global_properties["WALL_LAYER"]
	if wall_layer & area_node.collision_layer != 0:
		proj.is_expired = true
		return

	if target_node.has_method(proj.on_hit_call):
		target_node.call(proj.on_hit_call, proj)
	proj.on_pierced(area_rid)

At the end of this exercise, your player script should look something like this:

extends Node2D

@onready var projectile_caller: ProjectileCaller2D = $ProjectileCaller2D


func _ready() -> void:
	projectile_caller.get_projectile(0).set_on_collision(custom_collision)


func _input(event):
	if event is InputEventMouseButton:
		if event.button_index == MOUSE_BUTTON_LEFT and event.pressed:
			projectile_caller.request_projectile(0, position, get_global_mouse_position())


func custom_collision(proj: Projectile2D, area_rid: RID, area_node: Node2D, target_node: Node2D, area_shape_index: int, local_shape_index: int) -> void:
	if not proj.validate_collision(area_rid, area_node):
		return
	
	var wall_layer: int = proj.global_properties["WALL_LAYER"]
	if wall_layer & area_node.collision_layer != 0:
		proj.is_expired = true
		return

	if target_node.has_method(proj.on_hit_call):
		target_node.call(proj.on_hit_call, proj)
	proj.on_pierced(area_rid)