Build a dynamic wave spawner by using resources

:information_source: Attention Topic was automatically imported from the old Question2Answer platform.
:bust_in_silhouette: Asked By kuroski

Hello everyone,
I’m working on my first game and trying to implement a generic system that would allow me to define the monster waves of my game + apply custom properties in case I need them.

For example:

  • Wave 1
  • 4 Knight enemies
  • Wave 2
  • 4 Knight enemies
  • 1 Archer enemy
  • Wave 3
  • 4 Knight enemies + They all have 1 extra health point
  • 1 Boss

But I’m still struggling to make things work, primarily by my lack of understanding of how Resources and how the language works.

The idea is to have a ResourceStash
I can pre-load all my enemy’s scenes + export an enum which would be used by a Resource

# ResourceStash.gd
extends Node

enum ENEMIES {KNIGHT}
const EnemiesScenes = {
	KNIGHT = preload("res://Enemies/Knight.tscn")
}

func load_enemy_scene_for(enemy: ENEMIES):
	match enemy:
		ENEMIES.KNIGHT:
			return EnemiesScenes.KNIGHT

Then I have a EnemyStats resource:

# EnemyStats.gs
extends Resource
class_name EnemyStats

signal enemy_died

@export var max_speed: int
@export var max_health: int
@export var enemy: ResourceStash.ENEMIES
var health: set = set_health

# Here, the values are not being properly set 
func _init(max_speed = 15, max_health = 1):
	max_speed = max_speed
	max_health = max_health
	health = max_health

func set_health(value):
	health = clamp(value, 0, max_health)
	if health == 0:
		enemy_died.emit()

And finally, I can define my “generic” Resource that would allow me to build my waves dynamically:

## Waves.gs
extends Resource
class_name Waves

@export var waves: Array[Wave]

    #########
## Wave.gs
extends Resource
class_name Wave

@export var level: String
# Maybe later I could use a dictionary to provide more metadata about the waves
@export var enemies: Array[EnemyStats]
	
func _init(level = 0, enemies = []):
	level = level
	enemies = enemies

# .....
func _on_has_waved_cleared():
pass

    #########
## WaveSpawner.gd
extends Node2D

var Waves = preload("res://Waves/Waves.tres")

@onready var Spawner = $Spawner

func _ready():
	for enemy in Waves.waves[0].enemies:
		var enemy_scene = ResourceStash.load_enemy_scene_for(enemy.enemy)
		var main = get_tree().current_scene
		var instance = enemy_scene.instantiate()
		instance.stats = enemy
		main.add_child(instance, true)
		instance.global_position = position
		await get_tree().create_timer(randf_range(0.5, 1.5)).timeout

But I still do not understand:

  • Why is my WaveSpawner when I do instance.stats = enemy does not start my instance with the custom data
  • Does this approach seems reasonable? + Am I doing something wrong?

Thank you so much for your attention

Could you please explain what a “wave spammer” is?

Ertain | 2023-02-20 21:27

Hello Ertain,
Sorry for not providing details + I was also using the wrong word… I meant an “Enemy Spawner”.

I tried to add details to the description.

But in general, I’m building a game similar to
Norman the Necromancer | js13kGames

The idea is to have defined a resource that would allow me to specify my enemy waves.
For example:

  • Wave 1
    • 4 Knight enemies
  • Wave 2
    • 4 Knight enemies
    • 1 Archer enemy
  • Wave 3
    • 4 Knight enemies + They all have 1 extra health point
    • 1 Boss

Then based on that config I can create a class that would spam that + takes if the wave is done or not.

kuroski | 2023-02-21 05:20

:bust_in_silhouette: Reply From: kuroski

I think now I have managed to make what I wanted.
Now I need to make improvements.

Most of my issues were due to the following:

  • Instantiating resources in the wrong lifecycle callbacks
  • Bugs in Godot 4 Engine, sometimes you have to restart the engine after setting up a .tres file

Here is the code draft with the idea:

### RESOURCES

## Waves.gd
extends Resource
class_name Waves

@export var waves: Array[Wave]

## Wave.gd
extends Resource
class_name Wave

@export var level: String
@export var enemies: Array[EnemyStats]


## EnemyStats.gd
extends Resource
class_name EnemyStats

signal enemy_died

@export var max_speed: int
@export var max_health: int:
	set(value):
		max_health = value
		health = max_health
@export var enemy: ResourceStash.ENEMIES
var health: set = set_health

func set_health(value):
	health = clamp(value, 0, max_health)
	if health == 0:
		enemy_died.emit()

##### SCENES

## WaveSpawner.gd
extends Node2D

var Waves = preload("res://Waves/Waves.tres")

@onready var Spawner = $Spawner

func spawn_enemies():
	# TODO: work with signals to automatically handle each wave
	for enemy in Waves.waves[0].enemies:
		var enemy_scene = ResourceStash.load_enemy_scene_for(enemy.enemy)
		var instance = enemy_scene.instantiate()
		instance.stats = enemy.duplicate()
		get_tree().current_scene.add_child(instance, true)
		instance.global_position = Spawner.global_position
		await get_tree().create_timer(randf_range(0.5, 1.5)).timeout

## Enemy.gd
extends CharacterBody2D

@export var stats: EnemyStats:
	set(value):
		stats = value
		if not stats is EnemyStats: return
		
func _ready():
	self.stats = stats.duplicate()
	stats.enemy_died.connect(_on_EnemyStats_enemy_died)

func _on_Hurtbox_hit(damage):
	stats.health -= damage

func _on_EnemyStats_enemy_died():
	Utils.instance_scene_on_main(EnemyDeathEffect, global_position)
	queue_free()

## World.gd
extends Node2D

@onready var WaveSpawner := $WaveSpawner

func _ready():
	WaveSpawner.spawn_enemies()

With this definition, I can define each wave the way I want directly in a .tres file.
It is possible to provide as many levels as I need + customize my enemies.

Then in my main scene, I can use my WaveSpawner scene to automatically handle each enemy wave.

I still didn’t implement the features that will detect if the wave is done or not, but I hope this draft can show my general idea.

This looks very ellegant, but I am not sure if setting it up in export Array menu is comfortable. I would propably try to compose it with whole Nodes, so I could define waves by childing nodes and setting their variables. I believe it is more visual and doesn’t require to dig through nested submenus of exported arrays and dicitonaries.

Inces | 2023-02-21 13:29

Wow, thank you so much for this suggestion!

It’s my first time diving into game development (as a Hobby), and coming from a different area I ended up thinking of something way more complex than what it should have been.

I’ve just played around and definitely, this is waaay easier right now.

I’ve removed the @export var enemy: ResourceStash.ENEMIES and kept my EnemyStats (since I will evolve this file and create more configs for enemies later)

# EnemyStats.gs	extends Resource
class_name EnemyStats

signal enemy_died

@export var max_speed: int
@export var max_health: int:
	set(value):
		max_health = value
		health = max_health
		
var health: int:
	set(value):
		health = clamp(value, 0, max_health)
		if health == 0:
			enemy_died.emit()

Removed aaalll my resources.
Created a scene for each wave:

Then I just load the scene I want to spawn

# WaveSpawner.gd
extends Node2D

@onready var Spawner = $Spawner

func spawn_enemies(scene_path):
	var scene = load(scene_path)
	Utils.instance_scene_on_main(scene, Spawner.global_position)

# World.gd
extends Node2D

@onready var WaveSpawner := $WaveSpawner

func _ready():
	RenderingServer.set_default_clear_color(Color.BLACK)
	# TODO: Transform this to work with signals/load dynamically the waves
	WaveSpawner.spawn_enemies("res://Waves/Wave1.tscn")
	

What do you think about that?

kuroski | 2023-02-22 09:22

That is exactly what I was thinking about :).
Lately I noticed that Resources are being some hot topic in tutorials, but in fact they are very overrated in my opinion

Inces | 2023-02-22 21:04