Voltar para o Blog
Quest Log

Inimigo que Persegue o Jogador na Unity (2D e 3D)

Slime 2D e robô 3D lado a lado correndo atrás de um herói, com círculos de detecção desenhados no chão

Unity enemy follow player em 2D com Rigidbody2D.linearVelocity e em 3D com NavMeshAgent: raio de detecção, distância de parada e desistência, em C# do Unity 6.

Ferramenta:UnityC#

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ão Update. Velocidade de Rigidbody2D é física, e física roda no passo fixo. O tempo do contador usa Time.fixedDeltaTime pelo mesmo motivo.
  • linearVelocity preservando o Y. No Unity 6, velocity virou linearVelocity. Criar um Vector2 com 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 no Start, uma vez só. A API antiga deu lugar a FindFirstObjectByType e FindAnyObjectByType, 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.

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

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.speed ao 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.