Voltar para o Blog
Quest Log

Como Fazer um Tower Defense na Unity

Mapa em grade visto de cima com um caminho de terra, torres de canhão nas laterais e uma fila de inimigos avançando rumo a um castelo

Unity tower defense em grade: inimigos por waypoints, torre construída em célula livre com dinheiro, mira no inimigo mais avançado, ondas e vidas da base em C#.

Ferramenta:UnityC#

Como Fazer um Tower Defense na Unity

Um unity tower defense tem quatro sistemas: um mapa em grade com um caminho, inimigos que seguem esse caminho por waypoints, torres que o jogador constrói em células livres pagando com dinheiro, e ondas que testam a defesa até a base ficar sem vidas. Aqui você monta todos em C# para o Unity 6.3 LTS, com um detalhe que separa um TD jogável de um frustrante: a torre mira no inimigo mais avançado no caminho, não no mais perto.

O projeto é 3D com câmera vista de cima, mas tudo vale para 2D trocando os eixos.

Escopo do tower defense

O que entra:

  • Mapa 12x8 células, com um caminho fixo.
  • Inimigos que andam pelos waypoints e tiram 1 vida ao chegar na base.
  • Um tipo de torre: custa 50, alcance de 3,5 células, 1 tiro por segundo.
  • Construção: clique numa célula livre, fora do caminho, com dinheiro suficiente.
  • Mira no inimigo mais avançado dentro do alcance.
  • Ondas crescentes, dinheiro por inimigo morto, 20 vidas.

Fica de fora: vários tipos de torre, upgrades, venda, inimigos voadores. Tudo isso entra fácil depois. A versão para a outra engine, o tower defense na Godot, segue o mesmo roteiro.

Crie o projeto com o template Universal 3D. Câmera em Perspective com rotação X de 60 graus olhando para o centro do mapa, ou Orthographic com rotação X de 90 para um visual de tabuleiro. Crie as layers Ground e Enemy.

Mapa, grade e caminho

A grade não precisa de um objeto por célula. Ela é uma conta: dado um ponto no mundo, qual célula é essa, e dada uma célula, onde fica o centro dela. Uma matriz de bool guarda quais células estão ocupadas (por torre ou pelo caminho).

Arquivo GridMap.cs:

using UnityEngine;

public class GridMap : MonoBehaviour
{
    [SerializeField] private int width = 12;
    [SerializeField] private int height = 8;
    [SerializeField] private float cellSize = 1f;
    [SerializeField] private Transform[] pathWaypoints;

    private bool[,] blocked;

    public float CellSize => cellSize;

    private void Awake()
    {
        blocked = new bool[width, height];
        MarkPathCells();
    }

    public Vector2Int WorldToCell(Vector3 world)
    {
        Vector3 local = world - transform.position;
        return new Vector2Int(Mathf.FloorToInt(local.x / cellSize), Mathf.FloorToInt(local.z / cellSize));
    }

    public Vector3 CellToWorld(Vector2Int cell)
    {
        return transform.position + new Vector3((cell.x + 0.5f) * cellSize, 0f, (cell.y + 0.5f) * cellSize);
    }

    public bool IsInside(Vector2Int cell)
    {
        return cell.x >= 0 && cell.y >= 0 && cell.x < width && cell.y < height;
    }

    public bool IsFree(Vector2Int cell)
    {
        return IsInside(cell) && !blocked[cell.x, cell.y];
    }

    public void Occupy(Vector2Int cell)
    {
        blocked[cell.x, cell.y] = true;
    }

    private void MarkPathCells()
    {
        for (int i = 0; i < pathWaypoints.Length - 1; i++)
        {
            Vector3 a = pathWaypoints[i].position;
            Vector3 b = pathWaypoints[i + 1].position;
            int steps = Mathf.CeilToInt(Vector3.Distance(a, b) / (cellSize * 0.25f));
            for (int s = 0; s <= steps; s++)
            {
                Vector2Int cell = WorldToCell(Vector3.Lerp(a, b, (float)s / steps));
                if (IsInside(cell)) blocked[cell.x, cell.y] = true;
            }
        }
    }

    private void OnDrawGizmos()
    {
        Gizmos.color = new Color(1f, 1f, 1f, 0.15f);
        for (int x = 0; x < width; x++)
        {
            for (int y = 0; y < height; y++)
            {
                Vector3 center = CellToWorld(new Vector2Int(x, y));
                Gizmos.DrawWireCube(center, new Vector3(cellSize, 0.01f, cellSize));
            }
        }
    }
}

O caminho é definido por objetos vazios na cena (os waypoints), em ordem. O MarkPathCells anda por cada trecho entre waypoints em passos pequenos e marca as células por onde o caminho passa como bloqueadas. Assim você move um waypoint e o mapa de construção se ajusta sozinho. O OnDrawGizmos desenha a grade na Scene view para você posicionar os waypoints no centro das células.

O chão é um Plane do tamanho do mapa, na layer Ground, com o GridMap posicionado no canto inferior esquerdo dele.

Inimigos seguindo waypoints

O inimigo anda de waypoint em waypoint com Vector3.MoveTowards. O ponto importante para a mira: ele calcula o próprio progresso no caminho, um número que cresce do início ao fim. É esse número que a torre vai comparar. A técnica de seguir pontos é a mesma de uma patrulha por waypoints na Unity, só que aqui o caminho tem fim.

Arquivo Enemy.cs:

using System.Collections.Generic;
using UnityEngine;

public class Enemy : MonoBehaviour
{
    public static readonly List<Enemy> Alive = new List<Enemy>();

    [SerializeField] private float speed = 2f;
    [SerializeField] private int maxHealth = 6;
    [SerializeField] private int reward = 10;

    private Transform[] waypoints;
    private int targetIndex = 1;
    private int health;
    private GameState gameState;

    public float PathProgress { get; private set; }

    public void Init(Transform[] path, GameState state)
    {
        waypoints = path;
        gameState = state;
        transform.position = path[0].position;
        health = maxHealth;
    }

    private void OnEnable()
    {
        Alive.Add(this);
    }

    private void OnDisable()
    {
        Alive.Remove(this);
    }

    private void Update()
    {
        Vector3 target = waypoints[targetIndex].position;
        transform.position = Vector3.MoveTowards(transform.position, target, speed * Time.deltaTime);

        Vector3 previous = waypoints[targetIndex - 1].position;
        float segment = Vector3.Distance(previous, target);
        float remaining = Vector3.Distance(transform.position, target);
        PathProgress = (targetIndex - 1) + (segment > 0f ? 1f - remaining / segment : 1f);

        if (remaining < 0.01f)
        {
            targetIndex++;
            if (targetIndex >= waypoints.Length)
            {
                health = 0;
                gameState.LoseLife();
                Destroy(gameObject);
            }
        }
    }

    public void TakeDamage(int amount)
    {
        if (health <= 0) return;

        health -= amount;
        if (health <= 0)
        {
            gameState.AddMoney(reward);
            Destroy(gameObject);
        }
    }
}

PathProgress vale 0 no primeiro waypoint, 1 no segundo, 2,5 no meio do terceiro trecho, e assim por diante. Um inimigo com progresso 4,2 está mais perto da base que um com 3,9, mesmo que o segundo esteja fisicamente mais próximo da torre. A lista estática Alive registra cada inimigo ao ativar e remove ao desativar ou destruir, então as torres não precisam procurar inimigos na cena. O if (health <= 0) return; no começo do TakeDamage existe porque o Destroy só acontece no fim do frame: sem ele, dois projéteis que chegam no mesmo frame pagariam a recompensa duas vezes, e um tiro que alcança o inimigo no frame em que ele entra na base ainda daria dinheiro.

Tower defense na Unity: construir torres

O clique do mouse vira um raio da câmera até o chão. Se a célula atingida está livre e o jogador tem dinheiro, a torre é criada no centro dela.

Arquivo TowerBuilder.cs:

using UnityEngine;
using UnityEngine.InputSystem;

public class TowerBuilder : MonoBehaviour
{
    [SerializeField] private GridMap grid;
    [SerializeField] private GameState gameState;
    [SerializeField] private Tower towerPrefab;
    [SerializeField] private int towerCost = 50;
    [SerializeField] private LayerMask groundMask;

    private Camera cam;

    private void Awake()
    {
        cam = Camera.main;
    }

    private void Update()
    {
        Mouse mouse = Mouse.current;
        if (mouse == null || !mouse.leftButton.wasPressedThisFrame) return;

        Ray ray = cam.ScreenPointToRay(mouse.position.ReadValue());
        if (!Physics.Raycast(ray, out RaycastHit hit, 200f, groundMask)) return;

        Vector2Int cell = grid.WorldToCell(hit.point);
        if (!grid.IsFree(cell)) return;
        if (!gameState.TrySpend(towerCost)) return;

        grid.Occupy(cell);
        Instantiate(towerPrefab, grid.CellToWorld(cell), Quaternion.identity);
    }
}

A ordem das checagens importa: primeiro confere a célula, depois gasta o dinheiro. Ao contrário, o jogador perde 50 moedas clicando no caminho. Para mostrar ao jogador onde a torre vai cair, mova um cubo translúcido para grid.CellToWorld(cell) a cada frame e pinte de verde ou vermelho conforme IsFree. Esse "fantasma" da torre é o que deixa a construção clara.

Mira: escolher alvo e rotacionar

A torre tem uma base fixa e uma cabeça (head) que gira. Ela procura, entre os inimigos vivos dentro do alcance, o que tem o maior PathProgress.

Arquivo Tower.cs:

using UnityEngine;

public class Tower : MonoBehaviour
{
    [SerializeField] private Transform head;
    [SerializeField] private Transform muzzle;
    [SerializeField] private Projectile projectilePrefab;
    [SerializeField] private float range = 3.5f;
    [SerializeField] private float turnSpeed = 360f;
    [SerializeField] private float fireRate = 1f;
    [SerializeField] private float aimTolerance = 8f;
    [SerializeField] private int damage = 2;

    private Enemy target;
    private float nextShot;

    private void Update()
    {
        target = FindMostAdvancedInRange();
        if (target == null) return;

        Vector3 toTarget = target.transform.position - head.position;
        toTarget.y = 0f;
        if (toTarget.sqrMagnitude < 0.0001f) return;

        Quaternion desired = Quaternion.LookRotation(toTarget);
        head.rotation = Quaternion.RotateTowards(head.rotation, desired, turnSpeed * Time.deltaTime);

        bool aimed = Quaternion.Angle(head.rotation, desired) < aimTolerance;
        if (aimed && Time.time >= nextShot)
        {
            nextShot = Time.time + 1f / fireRate;
            Projectile p = Instantiate(projectilePrefab, muzzle.position, head.rotation);
            p.Launch(target, damage);
        }
    }

    private Enemy FindMostAdvancedInRange()
    {
        Enemy best = null;
        float bestProgress = float.MinValue;
        float rangeSqr = range * range;

        foreach (Enemy enemy in Enemy.Alive)
        {
            Vector3 offset = enemy.transform.position - transform.position;
            offset.y = 0f;
            if (offset.sqrMagnitude > rangeSqr) continue;

            if (enemy.PathProgress > bestProgress)
            {
                bestProgress = enemy.PathProgress;
                best = enemy;
            }
        }
        return best;
    }

    private void OnDrawGizmosSelected()
    {
        Gizmos.color = Color.cyan;
        Gizmos.DrawWireSphere(transform.position, range);
    }
}

Três decisões aqui. A distância é comparada ao quadrado (sqrMagnitude contra range * range), o que evita uma raiz quadrada por inimigo por torre por frame. O y é zerado para a torre não inclinar a cabeça quando o inimigo está num terreno mais alto. E a torre só atira quando Quaternion.Angle fica abaixo de 8 graus, então ela precisa terminar de virar antes de disparar: torres com turnSpeed baixo viram uma escolha de design (fortes, mas lentas para trocar de alvo).

Se preferir usar física, Physics.OverlapSphereNonAlloc(transform.position, range, buffer, enemyMask) preenche um array reaproveitado com os colliders no raio, sem gerar lixo, e você escolhe o de maior progresso entre eles. A lista estática dispensa collider nos inimigos e é mais simples para começar.

Com grade, caminho, construção e mira, a parte mecânica está de pé. E é a partir daqui que o tower defense fica difícil de verdade: balancear custo, dano, vida e ondas para que o jogo não seja fácil demais nem impossível, criar torres que se complementam, fazer interface de upgrade e testar com gente de fora. Esse trabalho de amarrar sistemas é onde quem estuda sozinho costuma ficar rodando em círculos.

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

Projéteis e dano

Arquivo Projectile.cs:

using UnityEngine;

public class Projectile : MonoBehaviour
{
    [SerializeField] private float speed = 12f;

    private Enemy target;
    private int damage;
    private Vector3 lastKnownPosition;

    public void Launch(Enemy newTarget, int newDamage)
    {
        target = newTarget;
        damage = newDamage;
        lastKnownPosition = newTarget.transform.position;
    }

    private void Update()
    {
        if (target != null)
        {
            lastKnownPosition = target.transform.position;
        }

        transform.position = Vector3.MoveTowards(transform.position, lastKnownPosition, speed * Time.deltaTime);

        if ((transform.position - lastKnownPosition).sqrMagnitude < 0.01f)
        {
            if (target != null) target.TakeDamage(damage);
            Destroy(gameObject);
        }
    }
}

O projétil teleguiado nunca erra, o que é o comportamento esperado na maioria dos TDs: o desafio está em onde colocar a torre, não na pontaria. O lastKnownPosition cobre o caso em que o alvo morre no meio do caminho (por outro tiro): o projétil termina o trajeto e some, em vez de dar erro de referência nula.

Ondas, dinheiro e vidas

Arquivo GameState.cs:

using UnityEngine;
using UnityEngine.Events;

public class GameState : MonoBehaviour
{
    [SerializeField] private int startMoney = 120;
    [SerializeField] private int startLives = 20;
    [SerializeField] private UnityEvent onGameOver;

    public int Money { get; private set; }
    public int Lives { get; private set; }
    public event System.Action Changed;

    private void Awake()
    {
        Money = startMoney;
        Lives = startLives;
    }

    public bool TrySpend(int amount)
    {
        if (Money < amount) return false;
        Money -= amount;
        Changed?.Invoke();
        return true;
    }

    public void AddMoney(int amount)
    {
        Money += amount;
        Changed?.Invoke();
    }

    public void LoseLife()
    {
        if (Lives <= 0) return;
        Lives--;
        Changed?.Invoke();
        if (Lives == 0) onGameOver.Invoke();
    }
}

Arquivo WaveSpawner.cs:

using System.Collections;
using UnityEngine;

public class WaveSpawner : MonoBehaviour
{
    [SerializeField] private Enemy enemyPrefab;
    [SerializeField] private Transform[] pathWaypoints;
    [SerializeField] private GameState gameState;
    [SerializeField] private int baseCount = 6;
    [SerializeField] private int extraPerWave = 3;
    [SerializeField] private float spawnInterval = 0.8f;
    [SerializeField] private float breakBetweenWaves = 6f;

    public int Wave { get; private set; }

    private IEnumerator Start()
    {
        while (gameState.Lives > 0)
        {
            Wave++;
            int count = baseCount + extraPerWave * (Wave - 1);
            for (int i = 0; i < count; i++)
            {
                Enemy enemy = Instantiate(enemyPrefab);
                enemy.Init(pathWaypoints, gameState);
                yield return new WaitForSeconds(spawnInterval);
            }

            yield return new WaitUntil(() => Enemy.Alive.Count == 0);
            yield return new WaitForSeconds(breakBetweenWaves);
        }
    }
}

Com 120 de dinheiro inicial e torre a 50, o jogador começa com 2 torres e sobra 20. Cada inimigo morto paga 10, então a primeira onda de 6 inimigos paga 60, o suficiente para mais uma torre. Essa conta é o coração do jogo: se a primeira onda pagar 200, o jogador nunca sente pressão. A pausa de 6 segundos entre ondas é o tempo de pensar e construir.

Erros comuns e balanceamento

  • Torre atirando no inimigo errado. Mira por distância em vez de progresso no caminho. Use PathProgress.
  • Torre atirando de costas. Faltou a tolerância de ângulo antes do tiro.
  • Construir em cima do caminho. As células do caminho não foram marcadas. Confira o MarkPathCells com os Gizmos.
  • Gastar dinheiro em clique inválido. Ordem errada das checagens.
  • Erro de referência nula no projétil. O alvo morreu antes do impacto. Guarde a última posição conhecida.
  • Inimigos "pulando" waypoints. Comparar posição com == em float. Use uma distância mínima, como no remaining < 0.01f.
  • Onda fácil depois da 5. Crescimento linear de inimigos com economia que também cresce. Aumente a vida dos inimigos por onda (por exemplo 15% a mais a cada onda) em vez de só a quantidade.

Uma forma de ver o balanceamento é contar o dano que a defesa consegue causar contra a vida que a onda traz. Uma torre com 2 de dano e 1 tiro por segundo causa 2 de dano por segundo. Um inimigo a 2 unidades por segundo atravessa um alcance de 3,5 células (7 de diâmetro, no melhor caso) em cerca de 3,5 segundos, então cada torre bem posicionada tira uns 7 de vida de cada inimigo que passa. Com inimigos de 6 de vida, uma torre na curva certa segura a primeira onda sozinha. Na onda 5, com 18 inimigos chegando a cada 0,8 segundo, ela não dá conta, e o jogador precisa ter juntado dinheiro para a terceira ou quarta torre.

Essa conta mostra duas alavancas que o jogador nem percebe: o tempo de exposição (curvas fazem o inimigo passar mais tempo no alcance, por isso o desenho do caminho é parte do balanceamento) e o intervalo entre inimigos (inimigos colados chegam juntos e sobrecarregam torres de tiro único, o que abre espaço para a torre de dano em área).

Escreva essas contas numa planilha antes de mexer no Inspector. Quando um playtest disser "a onda 7 é impossível", você sabe qual número mexer em vez de chutar.

Para afinar esses números com método, e não por tentativa, o post de fórmulas de balanceamento mostra curvas de custo e de dificuldade que se aplicam direto a tower defense.

Próximo passo

Acrescente um segundo tipo de torre: lenta e com dano em área (todos os inimigos num raio de 1 célula do impacto). A escolha entre as duas torres, e onde colocar cada uma, é o que transforma o protótipo em jogo de estratégia. Depois, uma opção de mira por torre (mais avançado, mais forte, mais perto) usando o mesmo laço do FindMostAdvancedInRange com outro critério de comparação.

Perguntas frequentes

Como fazer a torre mirar no inimigo na Unity?

Calcule a rotação desejada com Quaternion.LookRotation a partir da direção até o alvo, zerando o eixo vertical, e aproxime a rotação atual com Quaternion.RotateTowards usando uma velocidade em graus por segundo. A torre só atira quando o ângulo até o alvo fica abaixo de alguns graus, assim ela não dispara virada para o lado errado.

A torre deve mirar no inimigo mais perto?

Na maioria dos tower defense, não. O padrão é mirar no inimigo mais avançado no caminho, o que está mais perto da base. Mirar no mais perto faz a torre gastar tiros em quem acabou de entrar no alcance enquanto o inimigo perigoso passa. Muitos jogos oferecem as duas opções, além de mais forte e mais fraco.

Usar física ou uma lista para achar inimigos no alcance?

As duas funcionam. Physics.OverlapSphereNonAlloc com uma layer de inimigos devolve os colliders no raio sem gerar lixo. Uma lista estática de inimigos vivos, percorrida pela torre comparando distância ao quadrado, é ainda mais simples e dispensa colliders. Para algumas centenas de inimigos, a lista basta.

Tower defense é um bom primeiro jogo na Unity?

É um ótimo segundo jogo. O loop é claro e não precisa de controle de personagem, mas envolve grade, caminho, economia, ondas e balanceamento, que são sistemas conversando entre si. Faça antes algo bem menor para pegar o jeito do editor.