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:
- Rigidbody2D: Body Type
Dynamic, Gravity Scale0, Collision DetectionContinuous, InterpolateInterpolate. Mass pequena (0,05) para a bala não empurrar o inimigo como se fosse uma bola de boliche. - CircleCollider2D ou CapsuleCollider2D do tamanho do sprite, sem Is Trigger.
- 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()porWasPressedThisFrame(). 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
shotsPerSecondpassar de 60, troque oifpor umwhile (Time.time >= nextFireTime)e some1f / shotsPerSecondao próprionextFireTime, senão o jogo perde tiros em frames lentos. Antes do laço, façanextFireTime = Mathf.Max(nextFireTime, Time.time - Time.deltaTime);: sem isso, depois de um tempo sem atirar onextFireTimefica 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.
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:
- Estado reiniciado no
Launch. Posição, rotação, velocidade, tempo de vida e a flagreleasedsão definidos a cada disparo. Nada depende do que a bala era antes. IgnoreCollisionreaplicado a cada tiro. A documentação doPhysics2D.IgnoreCollisionavisa 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 oLaunchfaz.
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.



