GodotCourse/week5
2025-05-12 17:48:19 -04:00
..
original_project week 5 updated example files 2024-08-12 10:22:12 -04:00
updated_project adding player hurt 2024-10-07 19:20:42 -04:00
README.md format update, additional destroy function in docs 2025-05-12 17:48:19 -04:00

This week we do:

  • Level Loading, spawning scenes
  • Collectible items
  • Enemies
  • Player death

Multiple Levels

Having multiple levels means changing things so that the game controller remains persistent in memory across all other things in the display list getting reloaded, or even destroyed.

Moving the GameController

The game controller needs to be persistent across the entire life of the game. Godot provides a way to move a class outside of the display tree to live on its own in a persistent manner. It is called an 'autoload' and it can be found in project settings > globals.

When we have reassigned the game controller to an autoload, it can be accessed by the scene manager simply by class name - it, as well as any other code, will know how to find it.

Likewise the game controller should not directly reference any other class in the display list, since it is not longer a member of the display list. Instead, it should send out a signal that members of the display list can subscribe to.

Example - Updating timer

Now that the game controller is persistent, it does not reload when the level timer completes. As a result, the level loads over and over again because it thinks the time has run out.

We can use this example to show the relationship of the scene manager to the game controller - because the scene manager DOES reload each time the level is updated. As it loads, it will tell the game controller to reset the timer.

In Gamecontroller:

func reset():
	timeLimit = 10

And in the scene manager, we have a buildLevel function that gets called whenever the scene manager loads. This is a good place to tell the game controller to reset the timer.

func buildLevel()->void:
	Gamecontroller.reset()

Example - Destroy Signal (for crates)

Right now the scene manager counts up crates and tells the game controller how many there are.

Let's create a routine that allows the scene manager to connect signals from the triggers in our level to a function that will handle the logic.

Put all of your triggers in a blank Node2D, like we have done with our crates.

In Scenemanager>buildLevel:

for obj in triggers.get_children():
		if obj is Trigger:
			# wire up to GC
			obj.triggerFired.connect(Gamecontroller._on_trigger_fired)

We can now update the game controller with a custom signal it will emit to indicate that the crate should be destroyed when it hits the "destroy" trigger.

In Gamecontroller:

func _on_trigger_fired(effect: Variant, body: Variant) -> void:
	if effect=="destroy":
		if body is Crate:
			totalCrates -= 1
			print("Crates Remaining " + str(totalCrates) )
			destroySignal.emit(body)
		if totalCrates == 0:
			print("YOU WIN!")
			get_tree().reload_current_scene()

Now if something hits the "destroy" trigger, the game controller checks to see if it is a crate, and if so signals to destroy it. This is heard by the scene manager, who deletes the crate.

In Scenemanager:

func destroy(body):
	if body is Crate:
		body.queue_free()

Optionally, the Scenemanagercould now count up the number of remaining crates, and tell the Gamecontroller how many there are.

Example: Loading Scenes

Lets move the option to load scenes to the scene manager as well, and trigger it using a custom signal from the game controller.

In Scenemanager:

func loadLevel():
	get_tree().reload_current_scene()

In Gamecontroller, change any references to loading scenes to simple signal emits:

func _on_trigger_fired(effect: Variant, body: Variant) -> void:
	if effect=="destroy":
		if body is Crate:
			totalCrates -= 1
			print("Crates Remaining " + str(totalCrates) )
			destroySignal.emit(body)
		if totalCrates == 0:
			print("YOU WIN!")
			levelCompleteSignal.emit()
func secondCounter():
	timeLimit -=1
	if timeLimit <= 0:
		print("YOU LOSE")
		levelCompleteSignal.emit()

We have now freed the game controller from the roll of loading scenes. And we have seen how to make additions to the functionality of our game.

Multiple Levels

Copy your initial "game" level, and name the copies "level2" and "level3"

We can now create an array to keep track of each level.

var levels = ["res://scenes/game.tscn","res://scenes/level2.tscn","res://scenes/level3.tscn"]
var timers = [20,15,25]
var currentLevel = 0

Update the ready function of Gamecontroller to now use the timers array.

timeLimit = timers[currentLevel]

Likewise update the reset function:

func reset():
	timeLimit = timers[currentLevel]

Now, when the player loses we want to reload the current level. But if they win we want to load the next level.

Reload the current level by simply sending out a signal to loadLevel without updating the currentLevel.

func secondCounter():
	timeLimit -=1
	if timeLimit <= 0:
		print("YOU LOSE")
		levelCompleteSignal.emit(levels[currentLevel])

In SceneManager, update the loadLevel function to receive a string indicating what level to load. Note that we will do this using call_deferred which allows us to safely load a scene without interrupting currently running operations.

func loadLevel(level):
	get_tree().call_deferred("change_scene_to_file",level)

When the player wins, we follow a similar pattern, but we increase the current level before emitting the signal. We also check to see if you just finished the final level, in which case you return to the beginning.

Update the on_trigger_fired function:

if totalCrates == 0:
			print("YOU WIN!")
			currentLevel +=1
			#if on final level, go back to beginning
			if currentLevel >= levels.size():
				currentLevel=0
			levelCompleteSignal.emit(levels[currentLevel])

Congratulations - you can now have as many levels as you wish! Try editing your additional levels to make them interesting.


Collectible Items

Now that we know how to add things to the game loop, let's test our skills by having collectible coins.

The process will be:

  • Create a coin scene, and give it a custom signal when it is hit
  • Place some coins in the level in a coins holder
  • Have the scene manager loop through the coins wiring up their custom signal to the game controller
  • Have the game controller track how many coins are collected, and emit a signal to destroy the coin when collected

The coin scene will have code that looks like this:

class_name Coin extends Area2d

signal coinCollectedSignal(body, coin)

func _on_body_entered(body: Node2D) -> void:
	coinCollectedSignal.emit(body, self)

Now in the Gamecontroller, receive that signal.

func _on_coin_collected(body, coin):
	if body is Player:
		destroySignal.emit(coin)

And in the SceneManager, you can update the destroy function if you like:

func destroy(body):
	if body is Crate:
		body.queue_free()
	if body is Coin:
		body.queue_free()

Don't forget to add a coins node to your additional levels, or they will trigger an error when the Scenemanager tries to find it.


Bad Guys and Enemies

Adding a fun enemy to the level is remarkably similar to adding a collectible like a coin. The difference is that the enemy will be able to move around, and when we make contact with them the game controller will make a different decision - namely to hurt the player.

Just as with the coin, we will first create the bad guy. At a minimum, it will need a custom signal and a handler function for collisions.

Badguy script:

class_name Badguy extends Area2D

signal playerDamageSignal

func _on_body_entered(body: Node2D) -> void:
	playerDamageSignal.emit(body, self)

As you did with the coins, you can make a node2D called "badguys" and add it to the game. You can then drag a Badguy scene into it, and place the badguy where you wish.

Now we can update the Scenemanager to have it wire up the badguys signals to the Gamecontroller.

In Scenemanager:

#wire up badguys
	if badguys:
		for obj in badguys.get_children():
			if obj is Badguy:
				obj.playerDamageSignal.connect(Gamecontroller.playerDamage)

The playerDamage function in the Gamecontroller can be very simple for now. We will revisit the player damage in the future. For the time being, it can look like this:

func playerDamage(body, badguy):
	if body is Player:
		print("player got damaged")

Enemy Movement

To get the enemy moving around, we will use Raycast2D to have them detect obstacles, or if they are reaching the edge of a ledge.

We can use the _process function to update the position of the enemy every frame, and to update what direction they should be facing.

class_name Badguy extends Area2D
@onready var right_side_cast: RayCast2D = $RightSideCast
@onready var right_down_cast: RayCast2D = $RightDownCast
@onready var left_down_cast: RayCast2D = $LeftDownCast
@onready var left_side_cast: RayCast2D = $LeftSideCast
@onready var sprite: AnimatedSprite2D = $AnimatedSprite2D

var direction = 1
var speed = 100

signal playerDamageSignal

func _process(delta: float) -> void:
	if not right_down_cast.is_colliding():
		#about to fall on the right....
		direction = -1
		sprite.flip_h = true
	if not left_down_cast.is_colliding():
		direction = 1
		sprite.flip_h = false
		
	position.x += direction * speed * delta
	
func _on_body_entered(body: Node2D) -> void:
	playerDamageSignal.emit(body, self)