Creators – Week 10

This week we looked at:

  • Creating a custom resource to define a border and using it in mover.gd
  • Allowing our player ship to shoot missiles

Border Resource

To define a new border resource, we created a new script in the scripts folder that inherits Resource. Here’s the code:

class_name Border extends Resource

@export var Top : int 
@export var Bottom : int 
@export var Left : int 
@export var Right : int

It’s a very simple resource that just stores four values.

Note that we used “class_name Border” to ensure this new class had a name. This means, among other things, that we can create variables of this type in code.

In our mover.gd script, we added an additional exported variable of type Border to the class:

@export var direction : Vector2
@export var speed : float
@export var wander_time_min : float
@export var wander_time_max : float
@export var border : Border

Once we saved the file and looked at the inspector, we could see that this new exporter variable is there and by clicking on the drop down arrow, we could create a new Border resource and assign values to it. We entered the values 80, 20, 20 and 20 for the border.

In the mover.gd code we could then update our correct_dir_for_bounds() function to take this border into account. Note that they way we’ve structured the code, a border is optional:

func correct_dir_for_bounds() -> void:
	var top = 0
	var bottom = 0
	var left = 0
	var right = 0
	
	if (border):
		top = border.Top
		bottom = border.Bottom
		left = border.Left
		right = border.Right
	
	if (direction.x < 0 && parent.position.x < left):
		direction.x = 1
	if (direction.x > 0 && parent.position.x > vp_rect.size.x - right):
		direction.x = -1
	if (direction.y < 0 && parent.position.y < top):
		direction.y = 1
	if (direction.y > 0 && parent.position.y > vp_rect.size.y - bottom):
		direction.y = -1

This now keeps the enemy from going too close to the edge of the screen, especially at the top.

Creating a Missile

We went to Piskel and created a new 16×16 sprite to represent a missile, exported it and imported it into our Textures folder in Godot.

We then added a new Area2D to the scene and called it “Missile”. Under this we added a CollisionShape2D with a RectangleShape of size 16×16 and a Sprite2D containing the missile image.

We added a new script to MIssile:

class_name Missile extends Area2D

@export var direction : Vector2
@export var speed : float

# Called every frame. 'delta' is the elapsed time since the previous frame.
func _process(delta: float) -> void:
	position += direction * speed * delta
	rotation = Vector2.UP.angle_to(direction)

This code moves the missile in the given direction at the given speed, pointing it towards the direction of travel. Because we’ve specified a class name, we can access the variables here easily in other code.

We also wanted a way to ensure that missiles we create don’t go on for ever. A cheap and easy way to do this is with a timer. We added timer under the Missile node and set its time out to 3s and enabled both One Shot (fire the timer once and don’t repeat) and Autostart (start counting down as soon as the node appears in the tree).

We then needed to connect this timer to the missile code, we selected the Timer and opened the Node panel in the UI. Right-clicking on Timer – timeout and connected it to the missile.gd script with a function _on_timer_timeout(). All we need in _on_time_timeout() is to call queue_free(). Now the missile self destructs after three seconds.

Finally we dragged Missile from the tree into the scenes folder in the file system view, making it a separate scene. With that done. we could remove it from the scene.

Shooting

We went to the Project | Project Settings | Input Map and added a new Action called “Shoot”. We bound this to the spacebar and the left mouse button.

In ship.gd we added the following export variable to store the missile scene:

@export var missile_scene : PackedScene

We then added a _process() function to check for the shoot action being triggered and to spawn a missile a little ahead of the player and moving in the same direction the player is moving in:

const MISSILE_OFFSET : int = 32

func _process(delta: float) -> void:
	if (Input.is_action_just_pressed("Shoot")):
		var new_missile = missile_scene.instantiate() as Missile

		new_missile.direction = velocity.normalized()
		if (new_missile.direction == Vector2.ZERO):
			new_missile.direction = Vector2.UP
			
		new_missile.position = position + new_missile.direction * MISSILE_OFFSET
		
		get_parent().add_child(new_missile)

This code:

  1. Creates a new instance of the missile scene
  2. Sets the direction, based on the player’s velocity
  3. Ensures the direction isn’t zero, because this would mean the missile doesn’t move
  4. Sets the missile’s position a little ahead of the player
  5. Adds the new missile to the scene at the same level as player’s ship (we don’t want the missile to be a child of the ship or to move with it)

Rate Limiting Shots

Finally, we added a little code to implement a minimum time between shots. We added two new variables, one an export and the other internal:

@export var min_time_between_missiles : float = 0.1

var missile_countdown : float = 0

We updated _process() as follows:

func _process(delta: float) -> void:
	missile_countdown -= delta
	
	if (Input.is_action_just_pressed("Shoot") && missile_countdown < 0):
		var new_missile = missile_scene.instantiate() as Missile

		new_missile.direction = velocity.normalized()
		if (new_missile.direction == Vector2.ZERO):
			new_missile.direction = Vector2.UP
			
		new_missile.position = position + \
							   new_missile.direction * MISSILE_OFFSET
		
		get_parent().add_child(new_missile)
		missile_countdown = min_time_between_missiles

Every time we run _process() (i.e. every frame) we count down missile_countdown. Since it starts at zero, it will keep getting negative until we first shoot,. We only shoot when it is less than zero. If we shoot, we set it to min_time_between_missiles this means there can’t be another shot until this time has elapsed.

Getting the Code

All our code for this year is available on our GitHub.

Creators – Week 6

This week we added some pickups to our game, some signals (Godot’s term for messages or events) to indicate their creation or collection, and a game manager to keep track of everything.

Pickups

To create our pickups we created a new scene with an Area3D as the root node. An Area3D, in conjunction with a CollisionShape3D, allows us to define an area where bodies entering and exiting this area can be detected.

We paired this Area3D with a cylinder CollisionShape3D and a cylinder MeshInstance3D, both about the same height as our player ball.

We created a new StandardMaterial3D resource for our pickup and used the checker texture again, this time scaled to appear multiple times on the object, to give it some visual interest.

Signals

Signals are Godot’s way for nodes to tell other nodes when something’s happened. There are many built-in signals in the existing nodes and you can easily define others yourself.

To demonstrate, we created a new script attached to the root node of the pickup scene, and with this same node selected, we then went to the tab underneath the Inspector tab, a tab called Node.

This shows the list of signals on this node available to connect to. We right clicked on body_entered() and connected it to the pickup script, creating a new function called _on_body_entered() in that script.

We just set the following code there to show that we’d detected something entering the area (as defined by the collider):

func _on_body_entered(body: Node3D) -> void:
	print(body.name + " entered the area")

We created an instance of the pickup in our Main scene by using the “Instantiate Child Scene” button at the top of the scene view, and selecting the pickup scene:

We moved this pickup off centre and tested our code by playing it and rolling the player into the pickup. The message “Player entered the area” was printed to the Output window.

We updated this code by adding queue_free() at the end of the function:

func _on_body_entered(body: Node3D) -> void:
	print(body.name + " entered the area")
	queue_free()

This function means “please queue this node (and all it’s child nodes) for deletion at the end of this frame”. When we test again, we see that rolling the player into the pickup now makes the pickup disappear.

A Handy Way To Handle Events

In Godot connecting a node to another’s signal normally means that the first node has to have a reference to the second.

There’s a nice pattern that simplifies things. We make a central place that holds the signals, nodes that want to “emit” (or cause the signal to fire) and nodes that want to know when the signal was emitted just need to know about this central place, they don’t need to know about each other.

Godot has a system whereby a script can be set to autoload when the game starts, it doesn’t have to be connected to a node in a scene. Even more convenient, it will declare a global (visible everywhere) variable for this class instance that everyone can easily access.

This might sound complex, but it’s super easy in practice. We created a new script called events.gd. The contents were as follows:

extends Node

signal pickup_created
signal pickup_collected

We then went to Project Settings | Globals and under Autoload, selected the events.gd script and pressed “+ Add”

Firing the Events from Pickup

In pickup.gd we added two lines to our _ready() and _on_body_entered() functions to cause the appropriate signals to be emitted when the pickup was created and collected respectively:

func _ready() -> void:
	Events.pickup_created.emit()

func _on_body_entered(body: Node3D) -> void:
	print(body.name + " entered the area")
	queue_free()
	Events.pickup_collected.emit()

See that we’re using the global variable “Events” here to access the signals.

We tested that we could read these signals by putting code into Player, but as this was just for quick testing, we won’t replicate it here.

Game Manager

We created a new Node3D in the Main scene, called it Game Manager, and created a new script attached to it.

In this script we added two variables to the top of the class:

extends Node3D

@export var pickup_count : int = 0
@export var game_time : float = 0

One is an integer variable to keep track of the number of pickups. The second is a float variable that tracks how long the game has been running for. Both have “@export” at the start, this means that they both appear as variables in the Inspector, which is handy.

For the pickup count, we first added two new functions, to respond to the creation and collection signals respectively:

func _on_pickup_created() -> void:
	pickup_count += 1
	
func _on_pickup_collected() -> void:
	pickup_count -= 1

All these to is increase or decrease the pickup_count variable. We need then to connect them to the signals, so that they get called if the signal is emitted. We do that in _ready():

func _ready() -> void:
	Events.pickup_created.connect(_on_pickup_created)
	Events.pickup_collected.connect(_on_pickup_collected)

For game_time, all we have to do is keep adding the delta values available in _process(), which represent the time in seconds since the last frame was drawn, together:

func _process(delta: float) -> void:
	game_time += delta

To see these running we ran our game and in the Scene view, switched to the “Remote” tree. The remote tree is the tree of the game that’s running. Looking at the Game Manager node in the inspector, we could verify the variables were working as expected.

Getting the Code

All our code for this year is be available on our GitHub.