Voltar para o Blog
Quest Log

Spawner de Inimigos na Unity: Ondas e Posição Aleatória

Arena 2D vista de cima com inimigos surgindo nas bordas da tela em direção ao jogador no centro

Spawn de inimigos na Unity com ondas em ScriptableObject, posição aleatória fora da câmera e object pooling com UnityEngine.Pool, em C# do Unity 6.3.

Ferramenta:UnityC#

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:

  1. Parede: Physics2D.OverlapCircle(ponto, raio, layerDasParedes) devolve um collider se o ponto cair dentro de algo sólido.
  2. Jogador: distância mínima, para nunca nascer em cima dele quando a câmera está encostada no limite do mapa.
  3. Limite de tentativas: se 10 sorteios falharem, desista dessa vez. Laço while sem 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 Died uma vez, na criação. Se você fizesse o += no actionOnGet, 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 do if, 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.

Curso de Unity
Quer fazer seu primeiro jogo na Unity?

No curso de Unity 6 da CursoGame.Dev você vai do zero ao primeiro jogo, com um desafio prático em cada level e suporte individual no WhatsApp quando travar.

Conhecer o curso de Unity
Suporte no WhatsAppAcesso vitalícioGarantia de 7 dias

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: se true, acusa erro quando você devolve o mesmo objeto duas vezes. Deixe ligado em desenvolvimento.
  • defaultCapacity e maxSize: 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:

  1. Onda por onda à mão: Wave01 a Wave10 desenhadas uma a uma. É o mais controlado e o melhor para jogo com fim.
  2. Multiplicador: depois da última onda, repita a lista multiplicando count por 1,2 a cada ciclo e reduzindo o intervalo. Bom para modo infinito.
  3. Orçamento de pontos: cada tipo de inimigo custa pontos, e a onda N tem N * 10 pontos 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.