Como Fazer um Jogo de Nave na Unity (Shoot 'em Up)
Um jogo de nave na Unity cabe em poucas peças: uma nave que se move dentro da tela, tiros reciclados por um pool, inimigos que descem em padrões de curva, ondas que chamam esses inimigos e um chefe com duas fases. Aqui você monta todas elas em C# para o Unity 6.3 LTS, num shmup vertical 2D, com o código pronto para colar.
O cuidado que separa este projeto de um protótipo que engasga é um só: nunca criar e destruir bala a cada frame. Você vai ver por que e como evitar com a classe ObjectPool<T> que já vem na Unity.
Escopo do shmup
Antes de abrir o editor, defina o que entra. Um shmup vertical jogável tem:
- Nave do jogador: move em 8 direções, presa aos limites da câmera, atira segurando o botão.
- Tiro com cadência: um disparo a cada 0,1 segundo, não um por frame.
- 3 tipos de inimigo: reto, zigue-zague e mergulho.
- Ondas: uma lista de grupos de inimigos com intervalo entre eles.
- Chefe: duas fases, a segunda começa na metade da vida.
- Vida, HUD e game over.
Fica de fora, por enquanto: power-ups, menu de opções, rolagem de fundo com parallax e ranking online. Tudo isso é fácil de acrescentar quando o loop principal funciona. Se você quer ver o mesmo gênero construído na outra engine, o guia de shmup na Godot segue a mesma ordem de peças.
Configuração da cena: crie um projeto com o template Universal 2D, deixe a Main Camera em Projection: Orthographic com Size 5 e use uma resolução vertical no Game view (por exemplo 1080x1920). Crie as layers Player, Enemy, PlayerBullet e EnemyBullet e, em Edit > Project Settings > Physics 2D > Layer Collision Matrix, desmarque PlayerBullet x PlayerBullet, PlayerBullet x Player, EnemyBullet x Enemy e EnemyBullet x EnemyBullet. Isso tira metade das colisões inúteis antes de escrever uma linha.
Nave: movimento e limites da tela
A nave usa Rigidbody2D com Body Type: Kinematic e um CircleCollider2D marcado como Is Trigger. Kinematic porque a nave não precisa ser empurrada por nada: você define a posição e a física só detecta sobreposição.
Novos projetos do Unity 6 já vêm com o Input System ativo e um asset de ações padrão com o mapa Player, que tem as ações Move e Attack. O script lê essas ações direto de InputSystem.actions.
Arquivo ShipController.cs:
using UnityEngine;
using UnityEngine.InputSystem;
[RequireComponent(typeof(Rigidbody2D))]
public class ShipController : MonoBehaviour
{
[SerializeField] private float speed = 7f;
[SerializeField] private float edgePadding = 0.4f;
[SerializeField] private BulletPool bulletPool;
[SerializeField] private Transform muzzle;
[SerializeField] private float fireInterval = 0.1f;
private Rigidbody2D rb;
private InputAction moveAction;
private InputAction attackAction;
private Camera cam;
private Vector2 minBounds;
private Vector2 maxBounds;
private float nextFireTime;
private void Awake()
{
rb = GetComponent<Rigidbody2D>();
cam = Camera.main;
moveAction = InputSystem.actions.FindAction("Player/Move");
attackAction = InputSystem.actions.FindAction("Player/Attack");
}
private void Start()
{
CalculateBounds();
}
private void CalculateBounds()
{
Vector3 bottomLeft = cam.ViewportToWorldPoint(new Vector3(0f, 0f, 0f));
Vector3 topRight = cam.ViewportToWorldPoint(new Vector3(1f, 1f, 0f));
minBounds = new Vector2(bottomLeft.x + edgePadding, bottomLeft.y + edgePadding);
maxBounds = new Vector2(topRight.x - edgePadding, topRight.y - edgePadding);
}
private void Update()
{
if (attackAction.IsPressed() && Time.time >= nextFireTime)
{
nextFireTime = Time.time + fireInterval;
bulletPool.Fire(muzzle.position, Vector2.up);
}
}
private void FixedUpdate()
{
Vector2 input = moveAction.ReadValue<Vector2>();
Vector2 target = rb.position + input * speed * Time.fixedDeltaTime;
target.x = Mathf.Clamp(target.x, minBounds.x, maxBounds.x);
target.y = Mathf.Clamp(target.y, minBounds.y, maxBounds.y);
rb.MovePosition(target);
}
}
O ponto central é Camera.ViewportToWorldPoint. O viewport vai de (0,0) no canto inferior esquerdo até (1,1) no superior direito, independente da resolução. Convertendo esses dois cantos para coordenadas do mundo, você tem os limites reais da tela em qualquer celular ou monitor. Os edgePadding evitam que metade da nave suma na borda. Se o jogo permite trocar a resolução em tempo real, chame CalculateBounds() de novo quando ela mudar.
O tiro fica em Update com uma checagem de tempo: attackAction.IsPressed() diz se o botão está segurado, e nextFireTime impede mais de um disparo a cada fireInterval. Sem essa trava, a nave atira 60, 144 ou 240 balas por segundo, dependendo do monitor.
Jogo de nave na Unity: tiro e pool de balas
Aqui está o erro que derruba a maioria dos primeiros shmups: Instantiate a cada disparo e Destroy quando a bala sai da tela. Cada Instantiate aloca memória, cada Destroy gera lixo para o coletor, e quando o coletor roda o jogo dá um soluço visível. Com 10 tiros por segundo, mais os tiros dos inimigos, isso acontece o tempo todo.
A solução é o padrão de object pooling: criar as balas uma vez e reciclar. A Unity tem isso pronto em UnityEngine.Pool.ObjectPool<T>, com callbacks para criar, pegar, devolver e destruir.
Arquivo Bullet.cs (prefab com SpriteRenderer, Rigidbody2D Kinematic e CircleCollider2D com Is Trigger):
using UnityEngine;
using UnityEngine.Pool;
[RequireComponent(typeof(Rigidbody2D))]
public class Bullet : MonoBehaviour
{
[SerializeField] private float speed = 14f;
[SerializeField] private int damage = 1;
[SerializeField] private float lifetime = 2f;
private Rigidbody2D rb;
private IObjectPool<Bullet> pool;
private float despawnTime;
private bool released;
public void Init(IObjectPool<Bullet> ownerPool)
{
pool = ownerPool;
rb = GetComponent<Rigidbody2D>();
}
public void Launch(Vector2 position, Vector2 direction)
{
transform.position = position;
rb.linearVelocity = direction.normalized * speed;
despawnTime = Time.time + lifetime;
released = false;
}
private void Update()
{
if (Time.time >= despawnTime)
{
ReturnToPool();
}
}
private void OnTriggerEnter2D(Collider2D other)
{
if (other.TryGetComponent(out Health health))
{
health.TakeDamage(damage);
ReturnToPool();
}
}
private void ReturnToPool()
{
if (released) return;
released = true;
rb.linearVelocity = Vector2.zero;
pool.Release(this);
}
}
Arquivo BulletPool.cs:
using UnityEngine;
using UnityEngine.Pool;
public class BulletPool : MonoBehaviour
{
[SerializeField] private Bullet bulletPrefab;
[SerializeField] private int defaultCapacity = 64;
[SerializeField] private int maxSize = 256;
private ObjectPool<Bullet> pool;
private void Awake()
{
pool = new ObjectPool<Bullet>(
createFunc: CreateBullet,
actionOnGet: b => b.gameObject.SetActive(true),
actionOnRelease: b => b.gameObject.SetActive(false),
actionOnDestroy: b => Destroy(b.gameObject),
collectionCheck: false,
defaultCapacity: defaultCapacity,
maxSize: maxSize);
}
private Bullet CreateBullet()
{
Bullet bullet = Instantiate(bulletPrefab, transform);
bullet.Init(pool);
bullet.gameObject.SetActive(false);
return bullet;
}
public void Fire(Vector2 position, Vector2 direction)
{
Bullet bullet = pool.Get();
bullet.Launch(position, direction);
}
}
Três detalhes que importam. Primeiro, a flag released evita devolver a mesma bala duas vezes quando ela acerta um inimigo no mesmo frame em que o tempo de vida acaba; devolver duas vezes corrompe o pool. Segundo, collectionCheck: false é para a build; durante o desenvolvimento, deixe true para a Unity acusar justamente esse erro de dupla devolução. Terceiro, crie um BulletPool para as balas do jogador e outro para as dos inimigos, cada um com seu prefab e sua layer.
Inimigos com padrões de movimento
Um shmup fica interessante quando os inimigos não descem todos em linha reta. Você não precisa de spline para isso: uma direção base mais um deslocamento lateral calculado com seno já dá três padrões clássicos.
Arquivo EnemyMover.cs:
using UnityEngine;
public enum MovePattern { Straight, ZigZag, Dive }
public class EnemyMover : MonoBehaviour
{
[SerializeField] private MovePattern pattern = MovePattern.Straight;
[SerializeField] private float speed = 3f;
[SerializeField] private float amplitude = 1.5f;
[SerializeField] private float frequency = 2f;
[SerializeField] private float diveDelay = 1.2f;
private Vector3 origin;
private float elapsed;
private Transform player;
public void Setup(MovePattern newPattern, Transform target)
{
pattern = newPattern;
player = target;
}
private void OnEnable()
{
origin = transform.position;
elapsed = 0f;
}
private void Update()
{
elapsed += Time.deltaTime;
switch (pattern)
{
case MovePattern.Straight:
transform.position += Vector3.down * speed * Time.deltaTime;
break;
case MovePattern.ZigZag:
float y = origin.y - speed * elapsed;
float x = origin.x + Mathf.Sin(elapsed * frequency) * amplitude;
transform.position = new Vector3(x, y, 0f);
break;
case MovePattern.Dive:
if (elapsed < diveDelay || player == null)
{
transform.position += Vector3.down * (speed * 0.4f) * Time.deltaTime;
}
else
{
Vector3 dir = (player.position - transform.position).normalized;
transform.position += dir * (speed * 2f) * Time.deltaTime;
}
break;
}
if (transform.position.y < -8f)
{
gameObject.SetActive(false);
}
}
}
O zigue-zague calcula a posição a partir do tempo decorrido, não incrementa. Isso deixa a curva idêntica em qualquer frame rate. O mergulho faz o inimigo descer devagar por um tempo e então avançar na direção do jogador, o que obriga quem joga a se mexer.
Quando você quiser trajetórias longas e desenhadas à mão (o inimigo que entra pela esquerda, dá uma volta e sai por cima), o pacote Splines (com.unity.splines, instalado pelo Package Manager) permite desenhar a curva na cena e mover o inimigo com o componente Spline Animate. Para um primeiro shmup, o seno resolve.
Ondas de inimigos
Onda é dado, não código. Uma lista serializada no Inspector define quantos inimigos, qual padrão, o intervalo entre eles e a pausa até a próxima onda.
Arquivo WaveSpawner.cs:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
[System.Serializable]
public class Wave
{
public GameObject enemyPrefab;
public MovePattern pattern = MovePattern.Straight;
public int count = 6;
public float spawnInterval = 0.4f;
public float delayAfter = 2f;
}
public class WaveSpawner : MonoBehaviour
{
[SerializeField] private List<Wave> waves = new List<Wave>();
[SerializeField] private Transform player;
[SerializeField] private float spawnY = 6f;
[SerializeField] private float spawnXRange = 2.2f;
[SerializeField] private GameObject bossPrefab;
private IEnumerator Start()
{
foreach (Wave wave in waves)
{
float x = Random.Range(-spawnXRange, spawnXRange);
for (int i = 0; i < wave.count; i++)
{
Vector3 pos = new Vector3(x, spawnY, 0f);
GameObject enemy = Instantiate(wave.enemyPrefab, pos, Quaternion.identity);
enemy.GetComponent<EnemyMover>().Setup(wave.pattern, player);
yield return new WaitForSeconds(wave.spawnInterval);
}
yield return new WaitForSeconds(wave.delayAfter);
}
Instantiate(bossPrefab, new Vector3(0f, spawnY, 0f), Quaternion.identity);
}
}
Repare que todos os inimigos da mesma onda nascem no mesmo x. Isso cria uma fila que segue o mesmo padrão, que é o que dá cara de formação. Se a onda é zigue-zague, a fila inteira serpenteia junta. Os inimigos usam Instantiate porque nascem poucas dezenas por minuto; se o seu jogo virar um bullet hell com centenas de inimigos, aplique o mesmo pool das balas.
Com nave, tiro, inimigos e ondas, o seu shmup já roda. É aqui que muita gente trava: cada peça funciona sozinha, mas juntar tudo num jogo com chefe, menu, progressão de fases, som, feedback de acerto e build que roda bem no celular exige um caminho organizado, com alguém mostrando a ordem certa das decisões.
Chefe com fases
O chefe é um inimigo com mais vida e um comportamento que muda. A forma mais limpa de fazer isso é ouvir o evento de dano do componente de vida e trocar de fase quando a vida passa da metade.
Arquivo Health.cs, usado por nave, inimigos e chefe:
using UnityEngine;
using UnityEngine.Events;
public class Health : MonoBehaviour
{
[SerializeField] private int maxHealth = 3;
[SerializeField] private UnityEvent onDeath;
public int Current { get; private set; }
public int Max => maxHealth;
public event System.Action<int, int> Changed;
private void OnEnable()
{
Current = maxHealth;
}
public void TakeDamage(int amount)
{
if (Current <= 0) return;
Current = Mathf.Max(0, Current - amount);
Changed?.Invoke(Current, maxHealth);
if (Current == 0)
{
onDeath.Invoke();
gameObject.SetActive(false);
}
}
}
Arquivo BossController.cs:
using UnityEngine;
[RequireComponent(typeof(Health))]
public class BossController : MonoBehaviour
{
[SerializeField] private BulletPool enemyBullets;
[SerializeField] private float phaseOneInterval = 0.8f;
[SerializeField] private float phaseTwoInterval = 0.25f;
[SerializeField] private int spreadCount = 5;
[SerializeField] private float spreadAngle = 60f;
[SerializeField] private float moveSpeed = 2f;
[SerializeField] private float targetY = 3.2f;
private Health health;
private bool phaseTwo;
private float nextShot;
private float spiralAngle;
private void Awake()
{
health = GetComponent<Health>();
health.Changed += OnHealthChanged;
}
private void OnHealthChanged(int current, int max)
{
if (!phaseTwo && current <= max / 2)
{
phaseTwo = true;
}
}
private void Update()
{
if (transform.position.y > targetY)
{
transform.position += Vector3.down * moveSpeed * Time.deltaTime;
return;
}
float x = Mathf.Sin(Time.time * (phaseTwo ? 1.6f : 0.8f)) * 2f;
transform.position = new Vector3(x, targetY, 0f);
if (Time.time < nextShot) return;
if (phaseTwo)
{
nextShot = Time.time + phaseTwoInterval;
spiralAngle += 17f;
Vector2 dir = Quaternion.Euler(0f, 0f, spiralAngle) * Vector2.down;
enemyBullets.Fire(transform.position, dir);
}
else
{
nextShot = Time.time + phaseOneInterval;
float step = spreadAngle / (spreadCount - 1);
for (int i = 0; i < spreadCount; i++)
{
float angle = -spreadAngle / 2f + step * i;
Vector2 dir = Quaternion.Euler(0f, 0f, angle) * Vector2.down;
enemyBullets.Fire(transform.position, dir);
}
}
}
}
Na fase 1, o chefe dispara um leque de 5 tiros em intervalos longos: o jogador aprende a achar o buraco entre eles. Na fase 2, ele acelera o movimento lateral e passa a girar um tiro em espiral a cada 0,25 segundo, um padrão que exige movimento contínuo. A troca acontece uma vez só, na metade da vida, e o jogador sente a virada. Um flash branco no sprite ou um tremor de câmera nesse momento vendem a mudança.
Vida, HUD e game over
O mesmo Health serve para a nave. Coloque um Health com maxHealth = 3 no jogador e ligue o onDeath no Inspector a um método do GameManager que mostra a tela de game over.
Arquivo HealthHud.cs (usa TextMeshPro, que no Unity 6 faz parte do pacote de UI):
using TMPro;
using UnityEngine;
public class HealthHud : MonoBehaviour
{
[SerializeField] private Health playerHealth;
[SerializeField] private TMP_Text label;
private void OnEnable()
{
playerHealth.Changed += UpdateLabel;
UpdateLabel(playerHealth.Max, playerHealth.Max);
}
private void OnDisable()
{
playerHealth.Changed -= UpdateLabel;
}
private void UpdateLabel(int current, int max)
{
label.text = "Vidas: " + current;
}
}
Para o game over, um painel desativado no Canvas com um botão "Jogar de novo" que chama SceneManager.LoadScene(SceneManager.GetActiveScene().buildIndex) resolve. Pausar o jogo com Time.timeScale = 0f na morte e voltar para 1f ao reiniciar evita que inimigos continuem descendo atrás do painel.
Uma decisão de design que vale testar: em vez de morrer com um toque, dê ao jogador 1 segundo de invencibilidade piscando depois de cada dano. Sem isso, um inimigo encostado tira as 3 vidas em 3 frames.
Erros comuns de performance
- Instanciar bala a cada frame. O tiro em
Updatesem trava de cadência cria uma bala por frame. Com o pool, o problema vira pool cheio; sem o pool, vira coletor de lixo travando o jogo. A trava defireIntervalresolve os dois. - Bala que nunca volta para o pool. Se a bala só volta quando acerta algo, cada tiro que erra fica voando para sempre. O
lifetimegarante a devolução. - Collider sólido em bala. Bala com collider não-trigger empurra inimigos e gera cálculos de contato que você não usa. Bala é sempre trigger.
GetComponentdentro deUpdate. Guarde as referências emAwake. OTryGetComponentnoOnTriggerEnter2Dé aceitável porque roda só no contato.- Matriz de colisão toda marcada. Balas do jogador testando colisão com outras balas do jogador custam caro em telas cheias. Configure a Layer Collision Matrix logo no começo.
Camera.mainem todo frame. Nas versões recentes a Unity guarda essa referência, mas o hábito de buscar a câmera uma vez emAwakecontinua sendo o mais seguro.
Use o Window > Analysis > Profiler com o jogo rodando e olhe a linha GC Alloc do CPU Usage: num shmup bem montado, ela fica perto de zero durante o combate.
Próximo passo
Com o loop pronto, o próximo passo é dar peso ao combate. Acrescente um power-up que troca o tiro simples por um tiro triplo (são 3 chamadas de Fire com ângulos diferentes) e um flash de dano no inimigo. Depois, monte uma segunda fase com ondas mais densas e um chefe novo reaproveitando o BossController com outros números.
Se quer entender melhor como o projétil se comporta em outros tipos de jogo, leia o guia de tiro e projétil 2D na Unity, e para aprofundar o reaproveitamento de objetos em qualquer gênero, volte ao post de object pooling.
Perguntas frequentes
Como fazer um space shooter na Unity sem travar com muitas balas?
Use um pool de objetos em vez de Instantiate e Destroy a cada tiro. A classe ObjectPool<T> do namespace UnityEngine.Pool já resolve isso: você pega uma bala com Get() e devolve com Release() quando ela sai da tela ou acerta algo. O coletor de lixo para de trabalhar a cada disparo e o frame rate fica estável.
Qual a diferença entre shmup e jogo de nave?
Shmup é a abreviação de shoot em up, o gênero em que você atravessa uma fase atirando e desviando de projéteis. Quase todo jogo de nave 2D com tiro é um shmup. O termo cobre scroll vertical, horizontal e bullet hell, que é o subgênero com telas cheias de balas.
Preciso do pacote Splines para fazer os inimigos se moverem em curva?
Não. Para um primeiro jogo de nave, funções simples com seno e uma direção base já criam padrões em zigue-zague, mergulho e onda. O pacote Splines (com.unity.splines) vale a pena quando você quer desenhar trajetórias longas no editor e reutilizar em várias ondas.
Jogo de nave na Unity é melhor em 2D ou 3D?
Para começar, 2D com câmera ortográfica. A física 2D é mais simples, os limites da tela são fáceis de calcular e a arte é mais barata de produzir. Um shmup com modelos 3D e câmera ortográfica usa exatamente a mesma lógica de jogo, então dá para migrar depois.



