Spawner de Inimigos e Ondas (Waves) no Godot 4

Monte um spawner inimigos godot com Timer, Marker2D e ondas em array. Tutorial pratico de waves com dificuldade crescente no Godot 4 em GDScript.
Se voce ja fez seu primeiro jogo no Godot, provavelmente colocou os inimigos na cena na mao, arrastando cada um para a posicao. Funciona para um prototipo, mas vira um problema assim que voce quer fases mais longas, dificuldade que sobe ou inimigos infinitos. A solucao e construir um spawner inimigos godot: um sistema que instancia inimigos em tempo de execucao, controla quando e onde eles aparecem e organiza tudo em ondas (waves). Neste post voce vai montar esse sistema do zero, entendendo o porque de cada decisao e nao so copiando codigo.
Spawner de Inimigos e Ondas (Waves) no Godot 4
A ideia central e simples: voce tem uma cena de inimigo salva em disco, um no que sabe onde colocar esse inimigo e algum controle de tempo. A partir dai, da para escalar para qualquer tipo de jogo, de arena de horda a tower defense. Vamos por partes, comecando pela peca mais basica e somando complexidade ate chegar nas waves com dificuldade crescente.
Preparando a cena do inimigo
Antes de spawnar qualquer coisa, voce precisa de uma cena de inimigo pronta e salva como arquivo .tscn. Pode ser um CharacterBody2D com sprite, colisao e um script proprio. O ponto importante e que essa cena seja autossuficiente: ela deve saber se mover, tomar dano e morrer sozinha, sem depender do spawner.
Se o seu inimigo ainda nao tem comportamento, vale dar uma olhada em como fazer IA de inimigo perseguir e patrulhar, porque o spawner so coloca o inimigo no mundo. O que ele faz depois e responsabilidade do script do proprio inimigo.
Para o spawner conseguir carregar a cena, usamos preload quando o caminho e fixo. O preload carrega o recurso no momento em que o script e compilado, evitando travadas durante o jogo:
extends Node2D
const ENEMY_SCENE := preload("res://enemies/enemy.tscn")
Se voce quer trocar a cena pelo editor (por exemplo, ter spawners de inimigos diferentes), exporte a variavel em vez de usar uma constante:
@export var enemy_scene: PackedScene
A diferenca importa: preload e fixo e resolvido cedo; @export te da flexibilidade para configurar cada spawner na inspecao da cena.
Instanciando a cena de inimigo
Ter o PackedScene na mao nao basta. Voce precisa criar uma instancia dele e adicionar essa instancia a arvore de nos, senao nada aparece. O metodo e instantiate(), e depois add_child():
func spawn_enemy(position: Vector2) -> void:
var enemy := enemy_scene.instantiate()
enemy.global_position = position
add_child(enemy)
Note que setamos global_position antes ou depois do add_child. Definir antes evita que o inimigo apareca por um frame na posicao errada (geralmente 0,0). Esse detalhe e facil de errar e gera aquele "piscar" do inimigo no canto da tela.
Um ponto que confunde iniciantes: adicionar o filho ao proprio spawner (add_child(enemy)) faz com que o inimigo seja filho do no spawner. Em muitos jogos isso e ok, mas se o spawner se move ou e destruido, os inimigos vao junto. Para evitar acoplamento, e comum adicionar o inimigo a um no neutro, tipo um Node2D chamado Enemies que fica direto na cena principal:
@onready var enemies_container: Node2D = get_node("/root/Main/Enemies")
func spawn_enemy(position: Vector2) -> void:
var enemy := enemy_scene.instantiate()
enemy.global_position = position
enemies_container.add_child(enemy)
Se voce quiser entender melhor todas as nuances de instantiate, add_child e ownership de nos, escrevi um material so sobre instanciar cenas no Godot que cobre os casos de borda.
Definindo posicoes de spawn com Marker2D
Spawnar tudo no mesmo lugar fica artificial. O Marker2D resolve isso: e um no leve, sem visual no jogo, que serve so para marcar uma posicao no espaco. Voce coloca varios Marker2D na cena (nas bordas do mapa, por exemplo) e os organiza sob um no pai chamado SpawnPoints.
No script, voce pega todos os filhos desse no e sorteia um a cada spawn:
@onready var spawn_points: Node2D = $SpawnPoints
func get_random_spawn_position() -> Vector2:
var points := spawn_points.get_children()
if points.is_empty():
push_warning("Nenhum ponto de spawn configurado.")
return global_position
var chosen: Marker2D = points.pick_random()
return chosen.global_position
O pick_random() ja vem pronto em arrays no Godot 4 e devolve um elemento aleatorio. O push_warning no caso vazio nao e enfeite: ele te avisa no console se voce esqueceu de configurar os pontos, em vez de o jogo simplesmente nao spawnar nada e voce ficar caçando o motivo.
Controlando o ritmo com Timer
Agora precisamos de tempo. Spawnar todos os inimigos no mesmo frame entope a tela e mata a performance. Um Timer resolve isso disparando um sinal em intervalos regulares.
Adicione um no Timer como filho do spawner. Voce pode configurar pelo editor ou por codigo. Conectar o sinal timeout e o coracao do sistema:
@onready var spawn_timer: Timer = $SpawnTimer
func _ready() -> void:
spawn_timer.timeout.connect(_on_spawn_timer_timeout)
func _on_spawn_timer_timeout() -> void:
var pos := get_random_spawn_position()
spawn_enemy(pos)
Usar .connect() em vez de ligar o sinal pelo editor deixa a dependencia explicita no codigo, o que ajuda quando outra pessoa (ou voce daqui a tres meses) precisa entender o fluxo. Para iniciar o ciclo, basta chamar spawn_timer.start(intervalo).
Montando waves em um array
Spawnar inimigos para sempre no mesmo ritmo e o caso mais basico. Jogos de horda funcionam por ondas: cada wave tem uma quantidade de inimigos, um intervalo entre eles e uma pausa antes da proxima onda comecar. A forma mais limpa de modelar isso e um array de dicionarios, onde cada item descreve uma wave.
var waves: Array[Dictionary] = [
{"count": 5, "interval": 1.2, "rest": 3.0},
{"count": 8, "interval": 1.0, "rest": 3.0},
{"count": 12, "interval": 0.8, "rest": 4.0},
{"count": 18, "interval": 0.6, "rest": 5.0},
]
var current_wave := 0
var enemies_to_spawn := 0
Cada dicionario carrega tres dados: quantos inimigos spawnar (count), o tempo entre cada spawn (interval) e quanto tempo descansar antes da proxima onda (rest). Repare que, conforme as waves avancam, o count sobe e o interval cai. Esse e o jeito mais direto de fazer dificuldade crescente: mais inimigos, chegando mais rapido.
A logica de controle fica assim:
func start_wave() -> void:
if current_wave >= waves.size():
_on_all_waves_cleared()
return
var wave: Dictionary = waves[current_wave]
enemies_to_spawn = wave["count"]
spawn_timer.wait_time = wave["interval"]
spawn_timer.start()
func _on_spawn_timer_timeout() -> void:
if enemies_to_spawn <= 0:
spawn_timer.stop()
_finish_wave()
return
spawn_enemy(get_random_spawn_position())
enemies_to_spawn -= 1
O Timer continua disparando, mas agora cada disparo decrementa o contador. Quando chega a zero, o timer para e a wave e encerrada. Esse desenho mantem o spawn distribuido no tempo em vez de despejar tudo de uma vez.
Transicao entre ondas com pausa
Entre uma wave e outra, voce quer um respiro para o jogador. Da para reaproveitar o mesmo Timer ou usar um segundo. Usar await com get_tree().create_timer() deixa o fluxo de pausa bem legivel sem precisar de um no extra:
func _finish_wave() -> void:
var wave: Dictionary = waves[current_wave]
current_wave += 1
var rest_time: float = wave["rest"]
await get_tree().create_timer(rest_time).timeout
start_wave()
O create_timer cria um timer descartavel que se destroi sozinho depois de disparar. O await pausa a funcao naquele ponto e continua quando o tempo acaba. E uma forma enxuta de esperar sem encher a arvore de nos.
Vale um cuidado: se a cena for liberada da memoria durante esse await (por exemplo, o jogador morreu e voltou ao menu), a funcao pode tentar continuar em um no que ja nao existe. Em projetos maiores, cheque is_inside_tree() depois do await antes de seguir.
Esperando o jogador limpar a onda
Ate aqui as waves avancam por tempo. Muitos jogos preferem que a proxima onda so comece quando todos os inimigos da atual estiverem mortos. Para isso, precisamos contar inimigos vivos. Cada inimigo emite um sinal ao morrer, e o spawner escuta:
var alive_enemies := 0
func spawn_enemy(position: Vector2) -> void:
var enemy := enemy_scene.instantiate()
enemy.global_position = position
enemy.died.connect(_on_enemy_died)
enemies_container.add_child(enemy)
alive_enemies += 1
func _on_enemy_died() -> void:
alive_enemies -= 1
if alive_enemies <= 0 and enemies_to_spawn <= 0:
_finish_wave()
No script do inimigo, voce declara o sinal e o emite no momento da morte:
extends CharacterBody2D
signal died
func die() -> void:
died.emit()
queue_free()
Agora a onda so termina quando os dois contadores zeram: nao ha mais inimigos para spawnar e nenhum vivo na tela. Esse padrao por sinal mantem o inimigo desacoplado do spawner. O inimigo nao sabe que o spawner existe; ele so avisa "morri" e quem quiser que escute.
Cuidado com performance e instanciacao
Spawnar e destruir muitos inimigos por segundo gera pressao no coletor de memoria do Godot, porque cada instantiate() aloca e cada queue_free() desaloca. Em jogos de horda com centenas de inimigos, isso pode causar engasgos.
A tecnica para resolver isso e reaproveitar inimigos em vez de criar e destruir o tempo todo. Em vez de chamar queue_free(), voce esconde o inimigo e o devolve para uma fila, pegando-o de volta no proximo spawn. Esse padrao tem nome e vale a leitura dedicada sobre object pooling, que mostra quando o ganho compensa a complexidade extra. Para a maioria dos prototipos, instanciar normalmente ja resolve; otimize quando medir um problema real, nao antes.
Fechando o spawner
O que voce montou aqui e um esqueleto completo: uma cena de inimigo autossuficiente, instanciacao correta com posicionamento por Marker2D, ritmo controlado por Timer e waves descritas em um array de dicionarios com dificuldade crescente. A partir desse spawner inimigos godot, da para evoluir em varias direcoes: ler as waves de um arquivo JSON em vez de deixar no codigo, spawnar tipos diferentes de inimigo por onda, ou ligar o fim das waves a uma tela de vitoria.
O importante e que cada peca tem uma responsabilidade clara. O spawner decide quando e onde; o inimigo decide como se comportar e quando morre; o array de waves descreve o ritmo da partida. Mantendo essa separacao, voce troca qualquer parte sem quebrar as outras, e e isso que faz o sistema crescer junto com o seu jogo.


