Spawner de Inimigos na Unity: Ondas e Posição Aleatória
Um spawner de inimigos na Unity (o famoso unity spawn enemy que todo mundo busca no primeiro jogo de ação) precisa de três coisas: decidir o que nasce, onde nasce e quando a próxima leva entra. Aqui você monta as três em C# do Unity 6.3 LTS: ondas definidas em ScriptableObject, posição aleatória fora da câmera e longe de parede, contagem de inimigos vivos para liberar a próxima onda e object pooling com UnityEngine.Pool para não instanciar centenas de objetos.
No fim você tem um EnemySpawner que funciona em shooter de arena, survivor estilo horde ou tower defense simples, e que você ajusta mexendo em assets no Inspector, sem abrir o código para cada nova onda.
O que um spawner precisa resolver
Antes do código, vale separar as responsabilidades, porque é aqui que o spawner de primeiro projeto vira um script de 400 linhas que ninguém quer tocar:
- Dados da onda: quais inimigos, quantos de cada, com qual intervalo. Isso é design, muda toda hora, então fica fora do código, num asset.
- Posição: um ponto que o jogador não vê nascer, que não está dentro de parede e que não está colado no jogador.
- Ritmo: quando uma onda acaba e a próxima começa. O jeito mais legível é contar inimigos vivos.
- Custo: criar e destruir objeto o tempo todo gera lixo para o garbage collector. Pool resolve.
Cada item vira uma peça separada. O spawner só orquestra.
Spawn enemy simples na Unity com Instantiate e coroutine
Comece pelo mínimo, porque ele já serve para testar o inimigo. Crie SimpleSpawner.cs:
using System.Collections;
using UnityEngine;
public class SimpleSpawner : MonoBehaviour
{
[SerializeField] private GameObject enemyPrefab;
[SerializeField] private Transform[] spawnPoints;
[SerializeField] private float interval = 2f;
private void Start()
{
StartCoroutine(SpawnLoop());
}
private IEnumerator SpawnLoop()
{
WaitForSeconds wait = new WaitForSeconds(interval);
while (true)
{
Transform point = spawnPoints[Random.Range(0, spawnPoints.Length)];
Instantiate(enemyPrefab, point.position, Quaternion.identity);
yield return wait;
}
}
}
Dois detalhes que parecem bobos. O WaitForSeconds é criado uma vez fora do laço, porque criar um por iteração gera lixo à toa. E Random.Range com inteiros exclui o máximo, então Random.Range(0, spawnPoints.Length) nunca estoura o array.
Esse spawner tem dois problemas que aparecem em minutos de teste: ele não para nunca (a tela enche) e os pontos fixos ficam previsíveis. As próximas seções resolvem os dois.
Spawn em posição aleatória na Unity: fora da câmera e no chão válido
Pontos fixos colocados à mão funcionam bem em fase desenhada. Em arena aberta, o padrão é nascer logo fora da borda da câmera, assim o inimigo entra na tela andando em vez de surgir do nada na cara do jogador.
A conta usa coordenadas de viewport: (0,0) é o canto inferior esquerdo da câmera e (1,1) é o superior direito. Você sorteia um lado, sorteia um ponto ao longo dele e converte para o mundo com Camera.ViewportToWorldPoint. Depois empurra o ponto um pouco para fora, na direção oposta ao centro da câmera.
Para validar, três testes baratos:
- Parede:
Physics2D.OverlapCircle(ponto, raio, layerDasParedes)devolve um collider se o ponto cair dentro de algo sólido. - Jogador: distância mínima, para nunca nascer em cima dele quando a câmera está encostada no limite do mapa.
- Limite de tentativas: se 10 sorteios falharem, desista dessa vez. Laço
whilesem limite é como o jogo congela no dia em que a arena fica cheia.
Quando o spawn é em volta de um ponto (um portal, um ninho), use Random.insideUnitCircle * raio somado à posição do ponto. Ele devolve um Vector2 dentro de um círculo de raio 1, distribuído pela área, não só na borda.
Em 3D com NavMesh, a validação muda: em vez de OverlapCircle, você sorteia um ponto e chama NavMesh.SamplePosition (namespace UnityEngine.AI) para achar o ponto andável mais próximo dentro de uma distância máxima:
using UnityEngine;
using UnityEngine.AI;
public static class NavMeshSpawnUtil
{
public static bool TryGetPoint(Vector3 center, float radius, out Vector3 result)
{
Vector2 offset = Random.insideUnitCircle * radius;
Vector3 candidate = center + new Vector3(offset.x, 0f, offset.y);
if (NavMesh.SamplePosition(candidate, out NavMeshHit hit, 2f, NavMesh.AllAreas))
{
result = hit.position;
return true;
}
result = center;
return false;
}
}
Isso garante que o inimigo 3D nasce no chão que o NavMeshAgent consegue usar, e não flutuando sobre um buraco.
Ondas definidas em ScriptableObject
Agora a parte que separa o spawner de protótipo do spawner de jogo: as ondas viram assets. Um ScriptableObject é um arquivo de dados no projeto. Você cria quantos quiser pelo menu, edita no Inspector e o spawner só lê.
Crie WaveData.cs:
using System.Collections.Generic;
using UnityEngine;
[CreateAssetMenu(fileName = "Wave", menuName = "Jogo/Wave")]
public class WaveData : ScriptableObject
{
[System.Serializable]
public class SpawnGroup
{
[SerializeField] private Enemy prefab;
[SerializeField] private int count = 5;
[SerializeField] private float interval = 0.5f;
public Enemy Prefab => prefab;
public int Count => count;
public float Interval => interval;
}
[SerializeField] private float delayBeforeWave = 2f;
[SerializeField] private List<SpawnGroup> groups = new List<SpawnGroup>();
public float DelayBeforeWave => delayBeforeWave;
public IReadOnlyList<SpawnGroup> Groups => groups;
}
No Project, clique com o botão direito e use Create > Jogo > Wave. Crie Wave01, Wave02 e assim por diante. Cada onda tem grupos: "8 slimes com 0,4 s de intervalo, depois 2 morcegos com 1 s". Mudar o equilíbrio do jogo vira editar número no Inspector, inclusive em Play Mode para testar (lembrando que mudança em ScriptableObject durante o Play fica salva no asset).
O inimigo precisa de um componente que avise quando morreu e saiba voltar para o pool. Crie Enemy.cs:
using System;
using UnityEngine;
using UnityEngine.Pool;
public class Enemy : MonoBehaviour
{
public event Action<Enemy> Died;
private IObjectPool<Enemy> pool;
private bool released;
public void SetPool(IObjectPool<Enemy> owner)
{
pool = owner;
}
private void OnEnable()
{
released = false;
}
public void Die()
{
if (released) return;
released = true;
Died?.Invoke(this);
if (pool != null) pool.Release(this);
else Destroy(gameObject);
}
}
O Die() é o único caminho de saída do inimigo. Seu sistema de vida chama Die() quando a vida zera (o post de sistema de vida e dano na Unity monta essa parte), e a flag released impede que dois tiros no mesmo frame devolvam o inimigo duas vezes.
Contar inimigos vivos e liberar a próxima onda
O spawner abaixo junta tudo: lê as ondas, sorteia posição válida, pega o inimigo do pool e espera a contagem de vivos zerar antes de seguir. Crie EnemySpawner.cs:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Pool;
public class EnemySpawner : MonoBehaviour
{
[SerializeField] private List<WaveData> waves = new List<WaveData>();
[SerializeField] private Camera cam;
[SerializeField] private Transform player;
[SerializeField] private LayerMask blockingLayers;
[SerializeField] private float offscreenMargin = 1.5f;
[SerializeField] private float minDistanceFromPlayer = 4f;
[SerializeField] private float spawnCheckRadius = 0.5f;
[SerializeField] private int maxAttempts = 10;
private readonly Dictionary<Enemy, ObjectPool<Enemy>> pools = new Dictionary<Enemy, ObjectPool<Enemy>>();
private int aliveCount;
public int CurrentWave { get; private set; }
private void Start()
{
StartCoroutine(RunWaves());
}
private IEnumerator RunWaves()
{
for (int w = 0; w < waves.Count; w++)
{
CurrentWave = w + 1;
WaveData wave = waves[w];
yield return new WaitForSeconds(wave.DelayBeforeWave);
foreach (WaveData.SpawnGroup group in wave.Groups)
{
WaitForSeconds wait = new WaitForSeconds(group.Interval);
for (int i = 0; i < group.Count; i++)
{
if (TryGetSpawnPosition(out Vector2 position))
{
Enemy enemy = GetPool(group.Prefab).Get();
enemy.transform.SetPositionAndRotation(position, Quaternion.identity);
aliveCount++;
}
yield return wait;
}
}
yield return new WaitUntil(() => aliveCount <= 0);
}
Debug.Log("Todas as ondas foram vencidas.");
}
private void HandleEnemyDied(Enemy enemy)
{
aliveCount--;
}
private ObjectPool<Enemy> GetPool(Enemy prefab)
{
if (pools.TryGetValue(prefab, out ObjectPool<Enemy> existing))
{
return existing;
}
ObjectPool<Enemy> pool = null;
pool = new ObjectPool<Enemy>(
createFunc: () =>
{
Enemy created = Instantiate(prefab);
created.SetPool(pool);
created.Died += HandleEnemyDied;
return created;
},
actionOnGet: e => e.gameObject.SetActive(true),
actionOnRelease: e => e.gameObject.SetActive(false),
actionOnDestroy: e => Destroy(e.gameObject),
collectionCheck: true,
defaultCapacity: 20,
maxSize: 200);
pools.Add(prefab, pool);
return pool;
}
private bool TryGetSpawnPosition(out Vector2 position)
{
for (int attempt = 0; attempt < maxAttempts; attempt++)
{
Vector2 candidate = RandomPointOutsideCamera();
bool blocked = Physics2D.OverlapCircle(candidate, spawnCheckRadius, blockingLayers) != null;
bool tooClose = Vector2.Distance(candidate, player.position) < minDistanceFromPlayer;
if (!blocked && !tooClose)
{
position = candidate;
return true;
}
}
position = Vector2.zero;
return false;
}
private Vector2 RandomPointOutsideCamera()
{
int side = Random.Range(0, 4);
float t = Random.value;
Vector3 viewport = side switch
{
0 => new Vector3(t, 1f, 0f),
1 => new Vector3(t, 0f, 0f),
2 => new Vector3(0f, t, 0f),
_ => new Vector3(1f, t, 0f),
};
Vector2 edge = cam.ViewportToWorldPoint(viewport);
Vector2 center = cam.transform.position;
Vector2 outward = (edge - center).normalized;
return edge + outward * offscreenMargin;
}
}
Por que cada decisão:
- Um pool por prefab. O dicionário usa o próprio prefab como chave. Slime volta para o pool de slime, morcego para o de morcego.
- O spawner se inscreve no evento
Dieduma vez, na criação. Se você fizesse o+=noactionOnGet, cada reaproveitamento somaria mais um ouvinte e um único inimigo morto descontaria 3 ou 4 da contagem. WaitUntil(() => aliveCount <= 0)segura a coroutine até a onda morrer. É o ritmo clássico: limpa a tela, respira, vem a próxima.- Inimigo que não achou posição não conta. O
aliveCount++fica dentro doif, senão a onda nunca termina.
Essa é a parte em que o projeto costuma sair do trilho. O spawner sozinho funciona, mas no jogo inteiro ele conversa com vida, dano, pontuação, HUD de onda, câmera e tela de game over, e cada conexão mal feita vira um bug que só aparece na onda 7. Juntar essas peças numa arquitetura que aguenta crescer, sem gambiarra de Find espalhado, é exatamente o que um curso de Unity com projeto completo treina, porque você vê as peças conversando em vez de cada uma isolada.
Object pooling com UnityEngine.Pool
O ObjectPool<T> vem no namespace UnityEngine.Pool, sem instalar nada. O construtor recebe funções para cada momento da vida do objeto:
createFunc: como criar um novo quando o pool está vazio.actionOnGet: o que fazer ao tirar do pool (ativar).actionOnRelease: o que fazer ao devolver (desativar).actionOnDestroy: o que fazer quando o pool está cheio e descarta o excedente.collectionCheck: setrue, acusa erro quando você devolve o mesmo objeto duas vezes. Deixe ligado em desenvolvimento.defaultCapacityemaxSize: tamanho inicial da coleção interna e o máximo guardado.
O detalhe que pega muita gente: quando o inimigo volta do pool, ele volta com o estado que tinha ao morrer. Vida zerada, animação de morte, velocidade antiga no Rigidbody2D. Todo reset vai no OnEnable dos componentes do inimigo (vida cheia, linearVelocity = Vector2.zero, animator no estado inicial). A flag released do Enemy já segue essa regra.
Para uma explicação mais geral de quando o pool compensa e como medir, o post de object pooling e otimização mostra o conceito com outro exemplo, e a lógica é a mesma em qualquer engine.
Erros comuns: inimigo nascendo dentro da parede ou no jogador
Nasce dentro da parede. A layer das paredes não está marcada em blockingLayers, ou o raio de checagem é menor que o collider do inimigo. Use um raio igual ao maior lado do collider do inimigo.
Nasce em cima do jogador. Acontece quando a câmera encosta no limite do mapa e a borda da tela fica perto do jogador. A distância mínima resolve. Se o mapa for pequeno, reduza offscreenMargin ou use pontos fixos como plano B.
A onda nunca termina. Algum inimigo saiu de cena sem passar por Die(): caiu no buraco e foi destruído com Destroy, ou foi desativado por outro script. Regra: toda saída passa por Die(). Uma zona de morte embaixo do mapa chama Die() também.
InvalidOperationException ao devolver ao pool. É o collectionCheck avisando que o objeto foi liberado duas vezes. Procure quem chama Die() em dobro (dois colliders de dano no mesmo frame é o clássico).
Inimigo nasce na posição antiga por um frame. Ele é ativado no Get() e reposicionado logo depois. Se o inimigo tiver lógica em OnEnable que depende da posição, mova essa lógica para um método Spawn(Vector2 position) chamado pelo spawner depois de posicionar.
Próximo passo: dificuldade crescente
Com ondas em asset, subir a dificuldade é trabalho de design, não de código. Três jeitos que funcionam:
- Onda por onda à mão: Wave01 a Wave10 desenhadas uma a uma. É o mais controlado e o melhor para jogo com fim.
- Multiplicador: depois da última onda, repita a lista multiplicando
countpor 1,2 a cada ciclo e reduzindo o intervalo. Bom para modo infinito. - Orçamento de pontos: cada tipo de inimigo custa pontos, e a onda N tem
N * 10pontos para gastar. O spawner sorteia inimigos até o orçamento acabar. Dá variedade sem desenhar cada onda.
Se você também mexe com Godot, compare com o spawner de inimigos com waves no Godot: a arquitetura de dados separada da lógica é a mesma, muda só a sintaxe. O próximo passo natural aqui é ligar o Die() a um sistema de vida de verdade, com dano, invencibilidade e knockback.
Perguntas frequentes
Como fazer o inimigo nascer em posição aleatória na Unity?
Sorteie um ponto com Random.Range ou Random.insideUnitCircle e valide antes de usar: fora da câmera, longe do jogador e sem colidir com parede (Physics2D.OverlapCircle). Se o ponto falhar, sorteie de novo com um limite de tentativas para não travar o jogo.
Qual a diferença entre spawner simples e wave spawner na Unity?
O spawner simples cria inimigos num intervalo fixo para sempre. O wave spawner organiza o spawn em ondas com quantidade e tipo definidos, espera a onda morrer e só então libera a próxima, o que dá ritmo e permite subir a dificuldade.
Preciso de object pooling para spawn de inimigos?
Para poucas dezenas de inimigos por partida, Instantiate e Destroy funcionam. Quando o jogo cria e destrói centenas de inimigos (horde, survivor, shooter de ondas), o pool evita picos de coleta de lixo e deixa o frame estável. UnityEngine.Pool já vem na Unity, sem pacote extra.
Como evitar que o inimigo nasça dentro da parede?
Coloque as paredes numa layer própria e teste o ponto com Physics2D.OverlapCircle antes de spawnar. Em 3D, use NavMesh.SamplePosition para puxar o ponto para a área andável mais próxima.



