Inimigo que Persegue o Jogador na Unity (2D e 3D)
Para fazer um Unity enemy follow player, você precisa de três regras: detectar (o jogador entrou num raio), perseguir (mover na direção dele) e parar (a uma distância que não empurre o jogador). Em 2D, o movimento sai de Rigidbody2D.linearVelocity; em 3D, de um NavMeshAgent com SetDestination. Aqui você tem as duas versões lado a lado, com o mesmo comportamento: o inimigo vê o jogador, persegue, para na distância certa, desiste se o jogador fugir e volta para casa.
A desistência é o detalhe que separa inimigo de verdade de inimigo de tutorial. Sem ela, o inimigo segue o jogador pelo mapa inteiro, e o jogo perde a sensação de território.
A lógica de perseguição: detectar, perseguir, parar
Antes do código, o comportamento em três estados:
- Parado (Idle): o inimigo está em casa. Se o jogador entra no raio de detecção, passa a perseguir.
- Perseguindo (Chasing): anda na direção do jogador até a distância de parada. Se o jogador sai do raio de perda e fica fora por alguns segundos, desiste.
- Voltando (Returning): anda de volta até a posição inicial. Se no caminho o jogador entrar no raio de detecção de novo, volta a perseguir.
Dois raios, não um. O raio de perda é maior que o de detecção. Se fosse o mesmo valor, o jogador parado bem na borda faria o inimigo alternar entre perseguir e desistir a cada frame. Essa folga entre os dois chama histerese, e aparece em todo sistema de IA que você vai escrever.
A conta de distância e direção é subtração de vetores. Se esse assunto ainda é nebuloso, matemática para jogos: vetores explica com desenho. E se você quer ver a mesma lógica no Godot com máquina de estados completa, o post de IA de inimigo: perseguir e patrulhar faz isso em GDScript.
Inimigo seguir o jogador na Unity 2D
Versão de plataforma: o inimigo anda só no eixo X e a gravidade cuida do Y. Monte o inimigo com Rigidbody2D (Body Type Dynamic, Freeze Rotation Z ligado, Interpolate ligado), um CapsuleCollider2D ou BoxCollider2D e um SpriteRenderer. O jogador precisa da tag Player.
Crie EnemyChaser2D.cs:
using UnityEngine;
[RequireComponent(typeof(Rigidbody2D))]
public class EnemyChaser2D : MonoBehaviour
{
private enum ChaseState { Idle, Chasing, Returning }
[SerializeField] private Transform target;
[SerializeField] private SpriteRenderer spriteRenderer;
[SerializeField] private float moveSpeed = 3f;
[SerializeField] private float detectionRadius = 6f;
[SerializeField] private float loseRadius = 9f;
[SerializeField] private float stoppingDistance = 1.2f;
[SerializeField] private float resumeDistance = 1.5f;
[SerializeField] private float giveUpTime = 2f;
[SerializeField] private float homeTolerance = 0.1f;
private Rigidbody2D rb;
private ChaseState state = ChaseState.Idle;
private Vector2 homePosition;
private float outOfRangeTimer;
private bool isHoldingPosition;
private void Awake()
{
rb = GetComponent<Rigidbody2D>();
homePosition = rb.position;
}
private void Start()
{
if (target == null)
{
GameObject player = GameObject.FindWithTag("Player");
if (player != null)
{
target = player.transform;
}
}
}
private void FixedUpdate()
{
float distance = target != null
? Vector2.Distance(rb.position, target.position)
: float.MaxValue;
switch (state)
{
case ChaseState.Idle:
StopHorizontal();
if (distance <= detectionRadius)
{
state = ChaseState.Chasing;
}
break;
case ChaseState.Chasing:
UpdateChase(distance);
break;
case ChaseState.Returning:
UpdateReturn(distance);
break;
}
}
private void UpdateChase(float distance)
{
if (distance > loseRadius)
{
outOfRangeTimer += Time.fixedDeltaTime;
if (outOfRangeTimer >= giveUpTime)
{
outOfRangeTimer = 0f;
state = ChaseState.Returning;
return;
}
}
else
{
outOfRangeTimer = 0f;
}
float deltaX = target.position.x - rb.position.x;
float absDeltaX = Mathf.Abs(deltaX);
if (isHoldingPosition && absDeltaX > resumeDistance)
{
isHoldingPosition = false;
}
else if (!isHoldingPosition && absDeltaX <= stoppingDistance)
{
isHoldingPosition = true;
}
if (isHoldingPosition)
{
StopHorizontal();
Face(deltaX);
}
else
{
MoveHorizontal(Mathf.Sign(deltaX));
}
}
private void UpdateReturn(float distance)
{
if (distance <= detectionRadius)
{
state = ChaseState.Chasing;
return;
}
float deltaX = homePosition.x - rb.position.x;
if (Mathf.Abs(deltaX) <= homeTolerance)
{
StopHorizontal();
state = ChaseState.Idle;
return;
}
MoveHorizontal(Mathf.Sign(deltaX));
}
private void MoveHorizontal(float direction)
{
rb.linearVelocity = new Vector2(direction * moveSpeed, rb.linearVelocity.y);
Face(direction);
}
private void StopHorizontal()
{
rb.linearVelocity = new Vector2(0f, rb.linearVelocity.y);
}
private void Face(float direction)
{
if (spriteRenderer != null && Mathf.Abs(direction) > 0.01f)
{
spriteRenderer.flipX = direction < 0f;
}
}
private void OnDrawGizmosSelected()
{
Gizmos.color = Color.yellow;
Gizmos.DrawWireSphere(transform.position, detectionRadius);
Gizmos.color = Color.red;
Gizmos.DrawWireSphere(transform.position, loseRadius);
}
}
Os pontos que importam:
FixedUpdate, nãoUpdate. Velocidade de Rigidbody2D é física, e física roda no passo fixo. O tempo do contador usaTime.fixedDeltaTimepelo mesmo motivo.linearVelocitypreservando o Y. No Unity 6,velocityviroulinearVelocity. Criar umVector2com X novo e o Y atual mantém a gravidade e o pulo funcionando.- Sem
FindObjectOfType. O alvo vem do Inspector ou de uma busca por tag noStart, uma vez só. A API antiga deu lugar aFindFirstObjectByTypeeFindAnyObjectByType, mas para o jogador a tag é mais simples.
Para top-down, troque a parte do X por direção 2D completa: Vector2 direction = ((Vector2)target.position - rb.position).normalized; rb.linearVelocity = direction * moveSpeed; e coloque Gravity Scale em 0 no Rigidbody2D.
Versão 3D com NavMeshAgent
Em 3D, mover em linha reta faz o inimigo bater na primeira parede. O NavMeshAgent calcula o caminho em volta dos obstáculos. Você precisa de um NavMesh com bake na cena (o passo a passo do NavMesh Surface e do bake está no post de NavMesh na Unity 6). No inimigo, adicione NavMeshAgent.
Crie EnemyChaser3D.cs:
using UnityEngine;
using UnityEngine.AI;
[RequireComponent(typeof(NavMeshAgent))]
public class EnemyChaser3D : MonoBehaviour
{
private enum ChaseState { Idle, Chasing, Returning }
[SerializeField] private Transform target;
[SerializeField] private float detectionRadius = 10f;
[SerializeField] private float loseRadius = 15f;
[SerializeField] private float stoppingDistance = 1.8f;
[SerializeField] private float giveUpTime = 3f;
[SerializeField] private float repathInterval = 0.2f;
[SerializeField] private float turnSpeed = 8f;
private NavMeshAgent agent;
private ChaseState state = ChaseState.Idle;
private Vector3 homePosition;
private float outOfRangeTimer;
private float repathTimer;
private void Awake()
{
agent = GetComponent<NavMeshAgent>();
agent.stoppingDistance = stoppingDistance;
homePosition = transform.position;
}
private void Start()
{
if (target == null)
{
GameObject player = GameObject.FindWithTag("Player");
if (player != null)
{
target = player.transform;
}
}
}
private void Update()
{
if (!agent.isOnNavMesh)
{
return;
}
float distance = target != null
? Vector3.Distance(transform.position, target.position)
: float.MaxValue;
switch (state)
{
case ChaseState.Idle:
if (distance <= detectionRadius)
{
state = ChaseState.Chasing;
repathTimer = 0f;
}
break;
case ChaseState.Chasing:
UpdateChase(distance);
break;
case ChaseState.Returning:
if (distance <= detectionRadius)
{
state = ChaseState.Chasing;
repathTimer = 0f;
}
else if (!agent.pathPending && agent.remainingDistance <= agent.stoppingDistance)
{
agent.ResetPath();
state = ChaseState.Idle;
}
break;
}
}
private void UpdateChase(float distance)
{
if (distance > loseRadius)
{
outOfRangeTimer += Time.deltaTime;
if (outOfRangeTimer >= giveUpTime)
{
outOfRangeTimer = 0f;
state = ChaseState.Returning;
agent.SetDestination(homePosition);
return;
}
}
else
{
outOfRangeTimer = 0f;
}
repathTimer -= Time.deltaTime;
if (repathTimer <= 0f)
{
repathTimer = repathInterval;
agent.SetDestination(target.position);
}
if (distance <= agent.stoppingDistance)
{
FaceTarget();
}
}
private void FaceTarget()
{
Vector3 toTarget = target.position - transform.position;
toTarget.y = 0f;
if (toTarget.sqrMagnitude < 0.0001f)
{
return;
}
Quaternion look = Quaternion.LookRotation(toTarget);
transform.rotation = Quaternion.Slerp(transform.rotation, look, turnSpeed * Time.deltaTime);
}
}
Mesma máquina de estados, com o agente fazendo o trabalho pesado. O stoppingDistance do próprio NavMeshAgent já freia o inimigo antes do alvo, e o autoBraking (ligado por padrão) evita que ele passe do ponto.
Raio de detecção e distância de parada
Valores de partida que funcionam:
- Distância de parada: a soma dos raios dos dois colliders mais o alcance do ataque. Inimigo corpo a corpo com raio 0,5 contra jogador com raio 0,4 e ataque de 0,6 dá 1,5. Abaixo disso, ele encosta e empurra.
- Raio de detecção: menor que a metade da largura da tela em 2D. Se o inimigo começa a correr antes de aparecer na tela, o jogador sente injustiça.
- Raio de perda: 1,5 vez o de detecção.
- Tempo de desistência: 2 a 3 segundos. Menos que isso, o inimigo parece esquecido; mais, parece grudento.
No 2D, o resumeDistance ligeiramente maior que o stoppingDistance é a mesma histerese dos raios, agora para o movimento. Ele evita a tremedeira de andar e parar no limite exato.
Ajustar quatro números parece simples até você colocar o inimigo numa fase real, com animação de andar e atacar, dano, knockback, câmera e mais cinco inimigos ao mesmo tempo. É onde muita gente trava: cada tutorial ensina o inimigo isolado, e ninguém mostra como ele conversa com o resto do jogo sem virar um emaranhado de if.
Desistir da perseguição e voltar
O estado Returning dá território ao inimigo. Alguns ajustes que mudam a sensação:
- Velocidade de volta menor. Um inimigo que volta andando devagar parece "frustrado" e dá ao jogador uma janela para atacar pelas costas. No 3D, mude
agent.speedao entrar no Returning e restaure ao voltar a perseguir. - Regenerar vida na volta. Comum em RPG para impedir que o jogador abuse puxando o inimigo para fora do território.
- Avisar o jogador. Um som curto ou um ícone de interrogação quando o inimigo desiste mostra que a fuga funcionou. Sem esse sinal, o jogador não percebe a regra e não usa a mecânica a favor dele.
- Limitar pela distância de casa, não só do jogador. Some uma checagem: se o inimigo se afastou mais de 15 metros do
homePosition, ele volta mesmo vendo o jogador. Isso impede que um inimigo de uma sala atravesse a fase inteira. - Esquecer só se não viu. Combine com linha de visão: o contador de desistência só corre se o inimigo não enxerga o jogador. Isso é tema do post sobre campo de visão.
Virar o sprite ou rotacionar para o alvo
No 2D, o flipX do SpriteRenderer resolve, mas só vira o desenho. Se o inimigo tem filhos (uma arma, um ponto de disparo, um collider de ataque), o flipX não os move. Nesse caso, gire o objeto pai: transform.localScale = new Vector3(direction < 0f ? -1f : 1f, 1f, 1f);. Cuidado: escala negativa inverte também os colliders filhos, que é exatamente o que você quer para o hitbox de ataque.
No 3D, o NavMeshAgent gira o inimigo na direção do movimento (updateRotation ligado). Quando ele para perto do jogador, não há movimento e a rotação congela; por isso o FaceTarget gira manualmente com Quaternion.Slerp, zerando o Y para o inimigo não inclinar para cima ou para baixo.
Ligar a perseguição à animação
O script decide o movimento; o Animator só precisa saber quão rápido o inimigo está indo. Não crie um parâmetro por estado (isChasing, isReturning). Um único float de velocidade resolve andar, correr e parar, e continua certo quando você muda a lógica.
No 2D, dentro do FixedUpdate, depois do switch: animator.SetFloat("Speed", Mathf.Abs(rb.linearVelocity.x));. No 3D, no Update: animator.SetFloat("Speed", agent.velocity.magnitude);. No Animator Controller, um Blend Tree de Idle, Walk e Run lê esse valor. O ataque fica num trigger separado, disparado quando o inimigo está parado na distância de parada e o cooldown acabou.
Guarde o Animator em um campo [SerializeField] private Animator animator; e use Animator.StringToHash("Speed") num campo estático se tiver muitos inimigos: comparar inteiro é mais barato que procurar a string a cada frame.
Teste sempre com a animação ligada. Uma distância de parada que parece boa com um cubo pode ficar errada quando a animação de ataque estende o braço meio metro para a frente.
Erros comuns: inimigo empurrando o jogador, tremendo no alvo
Inimigo empurra o jogador. Distância de parada menor que a soma dos raios. Aumente. Se o contato físico não faz parte do jogo, desligue a colisão entre as layers Enemy e Player em Project Settings > Physics 2D > Layer Collision Matrix e use um trigger para o dano.
Inimigo treme parado no alvo. Falta de histerese (no 2D, resumeDistance igual ao stoppingDistance) ou SetDestination em todo frame com o alvo no limite. Use os dois limites e o intervalo de recálculo.
Inimigo 2D cai sem parar ou flutua. Você sobrescreveu o Y da velocidade com 0. Preserve rb.linearVelocity.y.
Inimigo 2D gira e capota. Freeze Rotation Z desligado no Rigidbody2D.
Inimigo 3D não se move e o console mostra erro de SetDestination. O agente não está no NavMesh. Confira o bake e a posição inicial.
Funciona com um inimigo e trava com vinte. Busca de objeto no Update, ou SetDestination por frame. Os dois scripts acima já evitam.
Próximo passo: patrulha e campo de visão
Um inimigo parado esperando o jogador é a versão mínima. O próximo passo é ele andar por uma rota enquanto não vê ninguém, o que é assunto do post de patrulha com waypoints na Unity. Depois, troque o raio de detecção por um campo de visão com ângulo e linha de visão, e o seu inimigo passa a ter comportamento de jogo stealth.
Perguntas frequentes
Como fazer um inimigo seguir o jogador na Unity 2D?
Calcule a direção do inimigo até o jogador, normalize e aplique em Rigidbody2D.linearVelocity dentro do FixedUpdate. Em plataforma, mexa só no eixo X e preserve a velocidade Y para a gravidade continuar funcionando. Acrescente um raio de detecção e uma distância de parada para o inimigo não colar no jogador.
Por que Rigidbody2D.velocity dá aviso no Unity 6?
A propriedade foi renomeada para linearVelocity no Unity 6. O nome antigo está obsoleto e o editor sugere a troca. O comportamento é o mesmo: velocidade em unidades por segundo.
Como fazer o inimigo desistir de perseguir o jogador?
Use dois raios: um menor para começar a perseguir e um maior para perder o alvo. Quando o jogador sai do raio maior, conte um tempo; se ele não voltar, o inimigo retorna para a posição inicial. A diferença entre os raios evita que o inimigo fique alternando entre perseguir e desistir na borda.
Como achar o jogador por script sem FindObjectOfType?
FindObjectOfType foi substituído por FindFirstObjectByType e FindAnyObjectByType. Mesmo assim, o melhor é arrastar a referência no Inspector ou buscar uma vez por tag no Start, nunca no Update.



