Como Fazer um Top-Down Shooter na Unity
Um unity top down shooter no estilo twin-stick tem duas entradas independentes: um eixo move o personagem e o outro aponta a arma. Aqui você monta isso em C# para o Unity 6.3 LTS com o Input System, de um jeito que o mesmo código funciona com teclado e mouse e com gamepad: movimento, mira, tiro com pool, inimigos que perseguem em ondas e câmera com Cinemachine e tremor de tela.
A decisão de arquitetura que faz tudo encaixar: o código de tiro nunca pergunta qual dispositivo está sendo usado. Ele recebe uma direção de mira. Quem decide de onde essa direção vem é um único script.
Escopo do top-down shooter
O que entra neste projeto:
- Jogador: anda em 8 direções, mira para qualquer lado, atira segurando o botão.
- Mira dupla: mouse no PC, analógico direito no gamepad, troca automática.
- Tiro com pool: nada de
Instantiatepor bala. - Inimigos: perseguem o jogador, morrem em 2 tiros, causam dano no contato.
- Ondas: cada onda maior que a anterior, nascendo fora da tela.
- Câmera: segue com leve atraso e treme quando o jogador leva dano.
- Vida, HUD e game over.
Fica para depois: armas diferentes, power-ups, mapa com obstáculos e pathfinding. A versão para a outra engine, o top-down shooter na Godot, segue a mesma estrutura se você quiser comparar.
Configuração: projeto Universal 2D, câmera Orthographic com Size 7, e em Edit > Project Settings > Physics 2D a gravidade em (0, 0). Crie as layers Player, Enemy e PlayerBullet e desmarque na Layer Collision Matrix o que não deve colidir (bala com jogador, bala com bala).
Movimento top-down com Rigidbody2D
O jogador tem Rigidbody2D Dynamic com Gravity Scale 0, Freeze Rotation Z marcado e Interpolate ligado, mais um CircleCollider2D. Dynamic porque ele precisa ser bloqueado por paredes e empurrado de leve por inimigos.
Arquivo TopDownMover.cs:
using UnityEngine;
using UnityEngine.InputSystem;
[RequireComponent(typeof(Rigidbody2D))]
public class TopDownMover : MonoBehaviour
{
[SerializeField] private float moveSpeed = 6f;
[SerializeField] private float acceleration = 60f;
private Rigidbody2D rb;
private InputAction moveAction;
private void Awake()
{
rb = GetComponent<Rigidbody2D>();
moveAction = InputSystem.actions.FindAction("Player/Move");
}
private void FixedUpdate()
{
Vector2 input = Vector2.ClampMagnitude(moveAction.ReadValue<Vector2>(), 1f);
Vector2 targetVelocity = input * moveSpeed;
rb.linearVelocity = Vector2.MoveTowards(rb.linearVelocity, targetVelocity, acceleration * Time.fixedDeltaTime);
}
}
ClampMagnitude impede que andar na diagonal com teclado seja mais rápido (o vetor (1,1) tem comprimento 1,41). O MoveTowards com aceleração alta dá uma resposta quase instantânea, mas sem o corte seco de setar a velocidade direto, o que deixa o controle mais agradável no analógico. Note o linearVelocity: no Unity 6 é esse o nome da propriedade. Para mais variações de movimento, como 4 direções e animação por direção, veja o guia de movimento top-down 2D na Unity.
Mira: mouse e analógico direito
O asset de ações padrão do Unity 6 já traz a ação Move com WASD, setas e analógico esquerdo, e o esquema de controle para Keyboard&Mouse e Gamepad. Para a mira, a ação Look padrão lê o delta do mouse, que serve para câmera em primeira pessoa mas não para apontar. Por isso o script de mira lê o analógico direito e a posição do mouse diretamente.
Arquivo AimController.cs:
using UnityEngine;
using UnityEngine.InputSystem;
public class AimController : MonoBehaviour
{
[SerializeField] private Transform weaponPivot;
[SerializeField] private float stickDeadzone = 0.25f;
private Camera cam;
private Vector2 aimDirection = Vector2.right;
private bool usingGamepad;
private Vector2 lastMousePosition;
public Vector2 AimDirection => aimDirection;
private void Awake()
{
cam = Camera.main;
}
private void Update()
{
Gamepad pad = Gamepad.current;
Mouse mouse = Mouse.current;
if (pad != null)
{
Vector2 stick = pad.rightStick.ReadValue();
if (stick.sqrMagnitude > stickDeadzone * stickDeadzone)
{
usingGamepad = true;
aimDirection = stick.normalized;
}
}
if (mouse != null)
{
Vector2 mousePos = mouse.position.ReadValue();
if ((mousePos - lastMousePosition).sqrMagnitude > 4f)
{
usingGamepad = false;
}
lastMousePosition = mousePos;
if (!usingGamepad)
{
Vector3 world = cam.ScreenToWorldPoint(new Vector3(mousePos.x, mousePos.y, -cam.transform.position.z));
Vector2 toMouse = (Vector2)(world - transform.position);
if (toMouse.sqrMagnitude > 0.01f)
{
aimDirection = toMouse.normalized;
}
}
}
float angle = Mathf.Atan2(aimDirection.y, aimDirection.x) * Mathf.Rad2Deg;
weaponPivot.rotation = Quaternion.Euler(0f, 0f, angle);
}
}
Como funciona a troca: se o analógico direito passa da zona morta, o jogo entra em modo gamepad e a direção é a do analógico. Se o mouse se move mais de 2 pixels, volta para modo mouse. Quando o analógico é solto, a mira fica na última direção, que é o comportamento que jogadores de twin-stick esperam.
A conversão do mouse é o ponto que mais dá bug. mouse.position está em pixels de tela. Camera.ScreenToWorldPoint converte para coordenadas do mundo considerando tamanho da câmera e resolução. O terceiro valor (z) é a distância da câmera ao plano do jogo; com câmera ortográfica ele não muda x e y, mas passar a distância correta mantém o código certo se um dia a câmera virar perspectiva.
Se você prefere trabalhar com o componente PlayerInput (Behavior: Invoke Unity Events), a mesma lógica vale: crie uma ação Aim com binding no <Gamepad>/rightStick e outra Point no <Mouse>/position, e use o evento de troca de controles para saber o esquema ativo. Para um jogador, ler os dispositivos direto como acima é mais curto.
Top-down shooter na Unity: tiro e pool
A arma lê a ação Attack e pede balas a um pool. No asset padrão, Attack vem com o botão esquerdo do mouse e o botão oeste do gamepad (X no Xbox, quadrado no PlayStation); em twin-stick o jogador espera atirar no gatilho, então abra o asset de ações e acrescente um binding <Gamepad>/rightTrigger na Attack. O pool usa UnityEngine.Pool.ObjectPool<T>, que já vem na Unity; o porquê de reciclar em vez de criar e destruir está explicado no post de object pooling. Num twin-stick, com 8 tiros por segundo durante minutos, Instantiate e Destroy por bala geram lixo constante e o coletor causa engasgos visíveis.
A bala é um prefab com SpriteRenderer, Rigidbody2D Kinematic e CircleCollider2D com Is Trigger, na layer PlayerBullet.
Arquivo Bullet.cs:
using UnityEngine;
using UnityEngine.Pool;
[RequireComponent(typeof(Rigidbody2D))]
public class Bullet : MonoBehaviour
{
[SerializeField] private float speed = 16f;
[SerializeField] private int damage = 1;
[SerializeField] private float lifetime = 1.5f;
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.SetPositionAndRotation(position, Quaternion.FromToRotation(Vector3.right, direction));
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;
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>(CreateBullet,
b => b.gameObject.SetActive(true),
b => b.gameObject.SetActive(false),
b => Destroy(b.gameObject),
false, defaultCapacity, maxSize);
}
private Bullet CreateBullet()
{
Bullet bullet = Instantiate(bulletPrefab, transform);
bullet.Init(pool);
return bullet;
}
public void Fire(Vector2 position, Vector2 direction)
{
pool.Get().Launch(position, direction);
}
}
A flag released impede que a mesma bala volte duas vezes ao pool (quando ela acerta algo no mesmo frame em que o tempo de vida acaba), o que corromperia o pool. A bala volta ao acertar qualquer coisa que a matriz de colisão deixa passar, inclusive paredes. O Health é compartilhado por jogador e inimigos.
Arquivo Health.cs:
using UnityEngine;
using UnityEngine.Events;
public class Health : MonoBehaviour
{
[SerializeField] private int maxHealth = 2;
[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 Weapon.cs:
using UnityEngine;
using UnityEngine.InputSystem;
public class Weapon : MonoBehaviour
{
[SerializeField] private AimController aim;
[SerializeField] private BulletPool bulletPool;
[SerializeField] private Transform muzzle;
[SerializeField] private float fireRate = 8f;
[SerializeField] private float spreadDegrees = 4f;
private InputAction attackAction;
private float nextShotTime;
private void Awake()
{
attackAction = InputSystem.actions.FindAction("Player/Attack");
}
private void Update()
{
if (!attackAction.IsPressed() || Time.time < nextShotTime) return;
nextShotTime = Time.time + 1f / fireRate;
float spread = Random.Range(-spreadDegrees, spreadDegrees);
Vector2 dir = Quaternion.Euler(0f, 0f, spread) * aim.AimDirection;
bulletPool.Fire(muzzle.position, dir);
}
}
O spreadDegrees de 4 graus deixa o tiro levemente impreciso. Parece detalhe, mas tiro perfeitamente reto em fila soa robótico; a dispersão pequena dá textura sem atrapalhar a pontaria.
Inimigos perseguindo e ondas
O inimigo tem Rigidbody2D Dynamic com gravidade zero e o mesmo componente Health, com maxHealth 2.
Arquivo ChaserEnemy.cs:
using UnityEngine;
[RequireComponent(typeof(Rigidbody2D))]
public class ChaserEnemy : MonoBehaviour
{
[SerializeField] private float speed = 3f;
[SerializeField] private int contactDamage = 1;
private Rigidbody2D rb;
private Transform target;
public void SetTarget(Transform player)
{
target = player;
}
private void Awake()
{
rb = GetComponent<Rigidbody2D>();
}
private void FixedUpdate()
{
if (target == null) return;
Vector2 dir = ((Vector2)target.position - rb.position).normalized;
rb.linearVelocity = dir * speed;
}
private void OnCollisionEnter2D(Collision2D collision)
{
if (collision.collider.TryGetComponent(out Health health) && collision.collider.CompareTag("Player"))
{
health.TakeDamage(contactDamage);
}
}
}
Arquivo WaveDirector.cs:
using System.Collections;
using UnityEngine;
public class WaveDirector : MonoBehaviour
{
[SerializeField] private ChaserEnemy enemyPrefab;
[SerializeField] private Transform player;
[SerializeField] private int firstWaveSize = 4;
[SerializeField] private int growthPerWave = 3;
[SerializeField] private float spawnRadius = 12f;
[SerializeField] private float timeBetweenWaves = 3f;
private int aliveCount;
private int wave;
private IEnumerator Start()
{
while (player != null)
{
wave++;
int count = firstWaveSize + growthPerWave * (wave - 1);
for (int i = 0; i < count; i++)
{
SpawnEnemy();
yield return new WaitForSeconds(0.15f);
}
yield return new WaitUntil(() => aliveCount == 0);
yield return new WaitForSeconds(timeBetweenWaves);
}
}
private void SpawnEnemy()
{
Vector2 offset = Random.insideUnitCircle.normalized * spawnRadius;
Vector3 pos = player.position + (Vector3)offset;
ChaserEnemy enemy = Instantiate(enemyPrefab, pos, Quaternion.identity);
enemy.SetTarget(player);
aliveCount++;
enemy.GetComponent<Health>().Changed += (current, max) =>
{
if (current == 0) aliveCount--;
};
}
}
Os inimigos nascem num círculo de raio 12 em volta do jogador. Com câmera de Size 7, isso fica fora da tela na maioria das proporções, então ninguém aparece do nada na cara do jogador. A onda seguinte só começa quando todos morrem e depois de 3 segundos de respiro: o ritmo tensão e alívio é o que segura quem joga.
Até aqui são cinco scripts conversando entre si, e o jogo já funciona. O que costuma travar quem faz sozinho vem agora: game feel, balanceamento das ondas, menus, som, salvar progresso e transformar o protótipo num jogo que outra pessoa termina. Cada uma dessas camadas mexe nas anteriores, e sem um caminho claro o projeto fica parado no "quase".
Câmera com Cinemachine e screen shake
Instale o Cinemachine pelo Package Manager. No Cinemachine 3, que é a versão para Unity 6, o componente de câmera virtual se chama CinemachineCamera (o antigo CinemachineVirtualCamera saiu) e o namespace é Unity.Cinemachine.
- GameObject > Cinemachine > Targeted Cameras > 2D Camera. Isso cria uma
CinemachineCamerae adiciona oCinemachineBrainna Main Camera. - No campo Tracking Target, arraste o jogador.
- No Position Composer, deixe Damping em 0,5 nos dois eixos e uma Dead Zone pequena: a câmera segue com leve atraso, o que dá sensação de peso.
- Em Add Extension, escolha CinemachineImpulseListener.
- No jogador, adicione Cinemachine Impulse Source e ajuste a força (Impulse Definition) para algo sutil.
Arquivo DamageShake.cs (no jogador):
using Unity.Cinemachine;
using UnityEngine;
[RequireComponent(typeof(Health))]
public class DamageShake : MonoBehaviour
{
[SerializeField] private CinemachineImpulseSource impulseSource;
private Health health;
private int lastHealth;
private void Awake()
{
health = GetComponent<Health>();
}
private void OnEnable()
{
lastHealth = health.Max;
health.Changed += OnHealthChanged;
}
private void OnDisable()
{
health.Changed -= OnHealthChanged;
}
private void OnHealthChanged(int current, int max)
{
if (current < lastHealth)
{
impulseSource.GenerateImpulse();
}
lastHealth = current;
}
}
O tremor só no dano do jogador, e não em cada tiro, é uma escolha: quando tudo treme, nada se destaca. Se quiser feedback no tiro, use um tremor muito menor numa segunda Impulse Source na arma.
Vida, HUD e game over
O jogador usa o mesmo Health, com maxHealth 5. Dê invencibilidade de 0,8 segundo depois de cada dano (um bool e um tempo, checados no TakeDamage), senão um grupo de inimigos encostado tira tudo em um frame. Ligue o onDeath a um GameManager que mostra o painel de game over com a onda alcançada, o número que o jogador quer bater na próxima tentativa.
A HUD é um TMP_Text que se inscreve em Health.Changed no OnEnable, se desinscreve no OnDisable e reescreve o texto a cada mudança. Para a onda atual, exponha wave no WaveDirector com uma propriedade de leitura e atualize o texto no começo de cada onda.
Erros comuns: mira errada em resolução diferente
- Usar
mouse.positioncomo posição do mundo. São pixels. Converta comScreenToWorldPoint. - Calcular a mira com
Screen.width / 2. Supõe que o jogador está no centro da tela, o que deixa de ser verdade quando a câmera tem atraso. Sempre parta da posição do jogador no mundo. - Câmera em perspectiva com z = 0 na conversão. O ponto convertido cai na própria câmera. Passe a distância até o plano do jogo.
- Mira presa no gamepad. Se o código nunca volta ao modo mouse, quem troca de dispositivo fica preso. Detecte movimento do mouse.
- Diagonal mais rápida. Faltou
ClampMagnitudeno input. velocityem vez delinearVelocity. No Unity 6 o nome mudou; código antigo copiado da internet gera aviso de API obsoleta.- Câmera tremendo sem parar. Impulse com duração longa demais ou chamado a cada frame. Chame só no evento.
Próximo passo
Coloque obstáculos na arena com TilemapCollider2D e perceba que os inimigos travam neles: perseguição em linha reta não contorna parede. Esse é o gancho para estudar pathfinding. Antes disso, acrescente uma segunda arma com tiro triplo e um inimigo que atira de longe, que muda o jeito de jogar mais que qualquer efeito visual. Para refinar o deslocamento, volte ao guia de movimento top-down linkado acima, e para comparar a mesma arquitetura em outra engine, o top-down shooter na Godot.
Perguntas frequentes
Como fazer um twin stick shooter na Unity com teclado e gamepad?
Separe o movimento da mira. O movimento lê a ação Move do Input System, que já tem bindings de WASD e do analógico esquerdo. A mira lê o analógico direito quando ele está inclinado e, caso contrário, a posição do mouse convertida para o mundo com ScreenToWorldPoint. O resto do código só recebe uma direção de mira e não sabe qual dispositivo a gerou.
Por que a mira do mouse fica errada na Unity?
Normalmente porque a posição do mouse está em pixels de tela e foi usada como se fosse posição do mundo, ou porque a conversão usou um valor de z errado numa câmera em perspectiva. Com câmera ortográfica, Camera.ScreenToWorldPoint resolve em qualquer resolução. Nunca calcule a mira dividindo por Screen.width na mão.
Como fazer screen shake com Cinemachine 3?
Adicione a extensão Cinemachine Impulse Listener na CinemachineCamera e um componente Cinemachine Impulse Source em quem causa o tremor, como a arma ou a explosão. No momento do impacto, chame GenerateImpulse() na source. A força e o formato do tremor ficam configurados no Inspector.
Top-down shooter na Unity deve usar física 2D ou 3D?
Para arte 2D, física 2D com Rigidbody2D, gravidade zero e câmera ortográfica. É mais leve e simples de configurar. Se o jogo tem modelos 3D vistos de cima, use física 3D e mova no plano XZ; a lógica de mira e tiro é a mesma, só troca o eixo.



