Voltar para o Blog
Quest Log

Tiro 2D na Unity: Projétil, Direção e Cadência

Nave 2D disparando uma sequência de projéteis luminosos na direção do cursor do mouse

Tiro 2D na Unity com projétil Rigidbody2D, mira no mouse pelo Input System, cadência por timer, dano e pooling, sem a bala acertar quem atirou. C# do Unity 6.

Ferramenta:UnityC#

Tiro 2D na Unity: Projétil, Direção e Cadência

Tiro 2D na Unity (o unity 2d shooting que todo jogo de nave, top down e run and gun precisa) se resolve com três peças: um prefab de projétil com Rigidbody2D, uma arma que decide direção e cadência, e um alvo que sabe receber dano. Aqui você monta as três em C# do Unity 6.3 LTS, com mira no mouse pelo Input System, cadência por timer e pooling.

E você resolve de cara o bug que aparece no primeiro teste de quase todo mundo: a bala nasce dentro do próprio jogador e explode nele.

Peças do tiro: arma, projétil e alvo

Separar responsabilidades evita o script Player.cs que lê input, instancia bala, calcula dano e toca som.

  • Arma (Weapon2D): lê o botão, decide a direção, controla a cadência e pede um projétil ao pool.
  • Projétil (Projectile2D): se move, vive por um tempo, detecta o acerto, aplica dano e volta ao pool.
  • Alvo: qualquer objeto com um componente que implemente IDamageable. A bala não quer saber se acertou inimigo, caixa ou barril.

O contrato do alvo é este, em IDamageable.cs:

using UnityEngine;

public interface IDamageable
{
    void TakeDamage(int amount, Vector2 hitSource);
}

Se você já tem um componente de vida, basta ele implementar essa interface.

Prefab do projétil com Rigidbody2D e collider

Crie um GameObject Bullet com um SpriteRenderer (um sprite de 8x4 pixels já basta para testar) e configure:

  1. Rigidbody2D: Body Type Dynamic, Gravity Scale 0, Collision Detection Continuous, Interpolate Interpolate. Mass pequena (0,05) para a bala não empurrar o inimigo como se fosse uma bola de boliche.
  2. CircleCollider2D ou CapsuleCollider2D do tamanho do sprite, sem Is Trigger.
  3. Uma layer nova chamada PlayerBullet.

Por que Continuous: com Discrete, a física checa colisão só na posição de cada passo. Uma bala a 20 unidades por segundo com passo de 0,02 s anda 0,4 unidade por passo, e um inimigo de 0,3 de largura pode ficar entre dois passos. Continuous calcula a trajetória entre os passos e pega o acerto.

Por que collider sólido e não trigger: o Continuous é pensado para contatos de colisão. Para bala lenta contra alvo largo, trigger resolve bem; com trigger em bala muito rápida você volta a correr o risco de atravessar. O collider sólido com OnCollisionEnter2D e devolução imediata ao pool resolve sem efeito colateral visível.

Agora o script, em Projectile2D.cs:

using UnityEngine;
using UnityEngine.Pool;

[RequireComponent(typeof(Rigidbody2D), typeof(Collider2D))]
public class Projectile2D : MonoBehaviour
{
    [SerializeField] private float speed = 18f;
    [SerializeField] private float lifetime = 2f;
    [SerializeField] private int damage = 1;

    private Rigidbody2D rb;
    private Collider2D bulletCollider;
    private IObjectPool<Projectile2D> pool;
    private float despawnTime;
    private bool released;

    private void Awake()
    {
        rb = GetComponent<Rigidbody2D>();
        bulletCollider = GetComponent<Collider2D>();
    }

    public void SetPool(IObjectPool<Projectile2D> owner)
    {
        pool = owner;
    }

    public void Launch(Vector2 position, Vector2 direction, Collider2D shooter)
    {
        released = false;
        Vector2 dir = direction.normalized;
        float angle = Mathf.Atan2(dir.y, dir.x) * Mathf.Rad2Deg;

        transform.SetPositionAndRotation(position, Quaternion.Euler(0f, 0f, angle));
        rb.position = position;
        rb.rotation = angle;

        if (shooter != null)
        {
            Physics2D.IgnoreCollision(bulletCollider, shooter, true);
        }

        rb.linearVelocity = dir * speed;
        despawnTime = Time.time + lifetime;
    }

    private void Update()
    {
        if (Time.time >= despawnTime)
        {
            Despawn();
        }
    }

    private void OnCollisionEnter2D(Collision2D collision)
    {
        IDamageable target = collision.collider.GetComponentInParent<IDamageable>();
        if (target != null)
        {
            target.TakeDamage(damage, transform.position);
        }
        Despawn();
    }

    private void Despawn()
    {
        if (released)
        {
            return;
        }
        released = true;

        if (pool != null)
        {
            pool.Release(this);
        }
        else
        {
            Destroy(gameObject);
        }
    }
}

Mathf.Atan2(y, x) devolve o ângulo do vetor em radianos. Multiplicar por Mathf.Rad2Deg dá graus, e o sprite da bala (desenhado apontando para a direita) gira para a direção do tiro. A velocidade usa linearVelocity, o nome da propriedade no Unity 6. O velocity dos tutoriais antigos está obsoleto.

O tempo de vida também é tratado por Time.time, sem coroutine por bala. Com centenas de projéteis na tela, centenas de coroutines viram custo e bagunça.

Tiro 2D na Unity: direção para frente e para o mouse

Os dois casos mais comuns: run and gun atira para onde o personagem olha, top down e twin stick atiram para o mouse. A arma abaixo faz os dois, em Weapon2D.cs:

using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.Pool;

public class Weapon2D : MonoBehaviour
{
    [SerializeField] private Projectile2D projectilePrefab;
    [SerializeField] private Transform muzzle;
    [SerializeField] private Collider2D ownerCollider;
    [SerializeField] private Camera cam;
    [SerializeField] private bool aimAtMouse = true;
    [SerializeField] private float shotsPerSecond = 8f;
    [SerializeField] private float spreadDegrees = 2f;

    private InputAction attackAction;
    private ObjectPool<Projectile2D> pool;
    private float nextFireTime;

    private void Awake()
    {
        attackAction = InputSystem.actions.FindAction("Attack");
        pool = new ObjectPool<Projectile2D>(
            createFunc: CreateProjectile,
            actionOnGet: p => p.gameObject.SetActive(true),
            actionOnRelease: p => p.gameObject.SetActive(false),
            actionOnDestroy: p => Destroy(p.gameObject),
            collectionCheck: true,
            defaultCapacity: 32,
            maxSize: 256);
    }

    private Projectile2D CreateProjectile()
    {
        Projectile2D created = Instantiate(projectilePrefab);
        created.SetPool(pool);
        return created;
    }

    private void Update()
    {
        if (attackAction == null || !attackAction.IsPressed())
        {
            return;
        }

        if (Time.time >= nextFireTime)
        {
            nextFireTime = Time.time + 1f / shotsPerSecond;
            Fire();
        }
    }

    private void Fire()
    {
        Vector2 direction = GetAimDirection();
        float spread = Random.Range(-spreadDegrees, spreadDegrees);
        direction = Quaternion.Euler(0f, 0f, spread) * direction;

        Projectile2D bullet = pool.Get();
        bullet.Launch(muzzle.position, direction, ownerCollider);
    }

    private Vector2 GetAimDirection()
    {
        if (aimAtMouse && Mouse.current != null)
        {
            Vector2 screenPos = Mouse.current.position.ReadValue();
            Vector2 worldPos = cam.ScreenToWorldPoint(screenPos);
            Vector2 toMouse = worldPos - (Vector2)muzzle.position;
            if (toMouse.sqrMagnitude > 0.0001f)
            {
                return toMouse.normalized;
            }
        }

        float facing = Mathf.Sign(transform.lossyScale.x);
        return new Vector2(facing, 0f);
    }
}

A ação Attack vem no asset de ações padrão que todo projeto novo do Unity 6 cria (InputSystem_Actions, mapa Player), ligada ao botão esquerdo do mouse e a um botão do controle, entre outros dispositivos (abra o asset para conferir e editar os bindings). InputSystem.actions acessa esse asset sem você precisar arrastar nada no Inspector.

A mira usa Mouse.current.position.ReadValue(), que devolve a posição em pixels da tela. ScreenToWorldPoint converte para coordenadas do mundo. Com câmera ortográfica (o padrão do template 2D) a conversão funciona direto. Com câmera em perspectiva você precisaria passar a distância da câmera até o plano do jogo no componente z.

Repare no fallback: sem mouse, a direção é (sinal da escala x, 0). É assim porque muita gente vira o personagem com scale.x = -1, e transform.right não inverte com a escala, só com a rotação. Usar muzzle.right nesse caso faz o personagem atirar para trás quando olha para a esquerda. Para aprofundar a rotação da arma e do braço seguindo o cursor, veja mirar no mouse em 2D na Unity.

Cadência de tiro com timer

A cadência é controlada por nextFireTime: o próximo tiro só sai quando Time.time passa desse valor, e a cada tiro ele avança 1 / shotsPerSecond. Com 8 tiros por segundo, um tiro a cada 0,125 s.

Por que timer e não uma coroutine por tiro (yield return new WaitForSeconds(0.125f)): a coroutine cria estado espalhado, é fácil disparar duas em paralelo com dois cliques rápidos e você dobra a cadência sem perceber. O timer é um número só, fácil de inspecionar no Debug.

Ajustes de design que saem desse mesmo número:

  • Semiautomática: troque IsPressed() por WasPressedThisFrame(). Um clique, um tiro, e o timer ainda limita a velocidade máxima de clique.
  • Rajada de 3: ao apertar, enfileire 3 tiros com 0,06 s entre eles e um timer maior depois.
  • Cadência acima do frame rate: se shotsPerSecond passar de 60, troque o if por um while (Time.time >= nextFireTime) e some 1f / shotsPerSecond ao próprio nextFireTime, senão o jogo perde tiros em frames lentos. Antes do laço, faça nextFireTime = Mathf.Max(nextFireTime, Time.time - Time.deltaTime);: sem isso, depois de um tempo sem atirar o nextFireTime fica lá atrás e o primeiro aperto solta uma rajada acumulada de uma vez.

Com direção e cadência resolvidas, o tiro já funciona isolado. O ponto em que o projeto pesa é quando ele precisa conversar com o resto: inimigo que morre e solta item, pontuação que sobe, HUD de munição, câmera que treme no disparo, som que não satura com 8 tiros por segundo. Juntar arma, inimigo, vida e feedback num jogo que você consegue terminar é o tipo de integração que um curso de Unity com projeto completo treina passo a passo.

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

Acertar o alvo e causar dano

No OnCollisionEnter2D, a bala procura IDamageable no objeto atingido e nos pais (GetComponentInParent), porque o collider do inimigo costuma estar num filho. Se achou, aplica dano passando a própria posição como origem do golpe, que o alvo usa para knockback ou partícula. Achando ou não, a bala some: acertou parede, acabou o tiro.

Para que a bala do jogador acerte só inimigo e cenário, configure as layers. Em Edit > Project Settings > Physics 2D, na matriz de colisão de layers (Layer Collision Matrix), desmarque PlayerBullet com Player e PlayerBullet com PlayerBullet. As balas param de colidir umas com as outras (o que, sem isso, desvia os tiros de uma rajada) e nunca encostam no jogador.

Destruir ou devolver ao pool

O Weapon2D cria um ObjectPool<Projectile2D> do namespace UnityEngine.Pool, que já vem com a Unity. Tirar do pool ativa a bala, devolver desativa. Com maxSize: 256, mesmo uma metralhadora segurada por minutos não acumula objetos além disso.

Dois cuidados específicos de projétil com pool:

  1. Estado reiniciado no Launch. Posição, rotação, velocidade, tempo de vida e a flag released são definidos a cada disparo. Nada depende do que a bala era antes.
  2. IgnoreCollision reaplicado a cada tiro. A documentação do Physics2D.IgnoreCollision avisa que o ignore se perde quando o collider é desativado, e desativar o GameObject desativa o collider junto. Como o pool desativa a bala ao devolver, o próximo disparo precisa ignorar o atirador de novo. É exatamente o que o Launch faz.

Se você quer o conceito de pooling com mais calma, incluindo quando ele compensa, o post de object pooling e otimização explica com outro exemplo.

Erros comuns: bala atravessando, bala acertando o atirador

A bala acerta quem atirou. O cano está dentro do collider do jogador e a bala nasce colidindo. Soluções, da mais simples à mais robusta: afaste o muzzle para fora do collider, use IgnoreCollision com o collider do dono (como no código) e separe as layers na Layer Collision Matrix. Use as três em jogo de verdade.

A bala atravessa o inimigo. Collision Detection ainda em Discrete, ou collider do inimigo muito fino para a velocidade. Continuous resolve a maioria. Se a bala for muito rápida, considere tiro instantâneo por raycast em vez de projétil físico.

A bala sai torta ou para trás. Sprite desenhado apontando para cima em vez de para a direita, ou uso de transform.right com personagem virado por escala. Desenhe projéteis apontando para +X.

A bala para no meio do caminho. O Rigidbody2D tem Linear Damping maior que 0 ou Gravity Scale diferente de 0. Projétil reto quer os dois zerados.

Erro de "already released" do pool. A bala bateu em dois colliders no mesmo passo e chamou Despawn duas vezes. A flag released impede isso.

Próximo passo

Com projétil, cadência e pool funcionando, os próximos degraus são variação de arma (espingarda com 5 projéteis e spread maior, laser contínuo) e feedback: recuo, partícula no cano, pausa de 2 frames no acerto. Para ver a mesma arquitetura implementada em outra engine e comparar decisões, leia sistema de tiro e projéteis, que monta o tiro no Godot com a mesma separação entre arma, bala e alvo.

Perguntas frequentes

Como fazer a bala ir na direção do mouse na Unity 2D?

Leia a posição do mouse com Mouse.current.position.ReadValue(), converta para o mundo com Camera.ScreenToWorldPoint e subtraia a posição do cano. Normalize o vetor resultante e use como direção da velocidade do Rigidbody2D do projétil.

Por que a bala atravessa o inimigo na Unity?

Projétil rápido pode pular o collider fino entre dois passos de física. Mude o Collision Detection do Rigidbody2D do projétil para Continuous e, se ainda falhar, aumente o collider do alvo ou reduza a velocidade.

Como impedir que o projétil acerte quem atirou?

Use Physics2D.IgnoreCollision entre o collider do projétil e o do atirador a cada disparo, ou coloque os projéteis numa layer que não colide com a layer do jogador na Layer Collision Matrix. Com pooling, reaplique o IgnoreCollision em cada tiro, porque ele é desfeito quando o objeto é desativado.

Instantiate ou object pooling para balas na Unity?

Para alguns tiros por segundo, Instantiate e Destroy funcionam. Em arma automática, shoot em up ou bullet hell, use UnityEngine.Pool.ObjectPool para reaproveitar os projéteis e evitar picos de coleta de lixo.