Voltar para o Blog
Quest Log

Campo de Visão do Inimigo na Unity: Ver, Ouvir e Perder

Guarda 3D com um cone de visão amarelo projetado no chão, bloqueado por uma parede, e o jogador escondido atrás de caixas

Unity field of view enemy para stealth: cone por ângulo com Vector3.Angle, Raycast contra paredes, audição por raio, memória da última posição e versão 2D.

Ferramenta:UnityC#

Campo de Visão do Inimigo na Unity: Ver, Ouvir e Perder

Um Unity field of view enemy precisa de três checagens, nesta ordem: o jogador está perto o bastante (raio de visão), está na frente (ângulo dentro do cone, com Vector3.Angle) e não tem parede no meio (um Physics.Raycast com a LayerMask das paredes). Aqui você monta um inimigo de stealth que vê com esse cone, ouve barulho por raio e intensidade, e perde o jogador com tempo de memória, indo até a última posição vista. Tem também o cone desenhado no editor e a versão 2D com Physics2D.

Perder o jogador é a parte que transforma detecção em jogo. Um guarda que esquece instantaneamente é bobo; um que nunca esquece é injusto. A memória com tempo é o que cria a tensão de se esconder.

Campo de visão do inimigo na Unity: as três checagens

A ordem das checagens é uma decisão de desempenho:

  1. Distância: comparar distância ao quadrado é quase de graça. A maior parte do tempo, o jogador está longe e o teste termina aqui.
  2. Ângulo: uma conta de vetor. Elimina o jogador que está perto, mas atrás.
  3. Linha de visão: o Raycast é a checagem mais cara, porque consulta a física. Só roda quando as duas primeiras passaram.

Os vetores por trás (direção, normalização, ângulo entre dois vetores) estão explicados com desenho em matemática para jogos: vetores. E o design do stealth em si (estados de suspeita, esconderijos, como o jogador lê o guarda) é tema do post sobre como fazer jogo stealth. Aqui o foco é a implementação na Unity.

Distância e ângulo com Vector3.Angle

Vector3.Angle(a, b) devolve o ângulo em graus entre dois vetores, sempre entre 0 e 180. Se o inimigo tem um cone de 90 graus, o jogador está dentro quando o ângulo entre transform.forward e a direção até o jogador é menor que 45, a metade do cone. Esquecer a metade é o erro mais comum: o cone fica com o dobro do tamanho.

Prepare as layers antes do código: crie Player e Obstacle em Edit > Project Settings > Tags and Layers, coloque o jogador em Player e paredes, caixas e portas em Obstacle.

Crie EnemyVision.cs:

using UnityEngine;

public class EnemyVision : MonoBehaviour
{
    [SerializeField] private Transform eye;
    [SerializeField] private float viewRadius = 12f;
    [SerializeField, Range(0f, 360f)] private float viewAngle = 90f;
    [SerializeField] private LayerMask targetMask;
    [SerializeField] private LayerMask obstacleMask;
    [SerializeField] private float targetHeightOffset = 1.2f;

    private readonly Collider[] overlapBuffer = new Collider[8];

    public float ViewRadius => viewRadius;
    public float ViewAngle => viewAngle;

    public bool TrySeeTarget(out Transform seenTarget)
    {
        seenTarget = null;
        Vector3 eyePosition = eye != null ? eye.position : transform.position;

        int count = Physics.OverlapSphereNonAlloc(eyePosition, viewRadius, overlapBuffer, targetMask);
        for (int i = 0; i < count; i++)
        {
            Transform candidate = overlapBuffer[i].transform;
            Vector3 targetPoint = candidate.position + Vector3.up * targetHeightOffset;
            Vector3 toTarget = targetPoint - eyePosition;

            Vector3 flatToTarget = new Vector3(toTarget.x, 0f, toTarget.z);
            Vector3 flatForward = new Vector3(transform.forward.x, 0f, transform.forward.z);
            if (Vector3.Angle(flatForward, flatToTarget) > viewAngle * 0.5f)
            {
                continue;
            }

            float distance = toTarget.magnitude;
            if (Physics.Raycast(eyePosition, toTarget / distance, distance, obstacleMask, QueryTriggerInteraction.Ignore))
            {
                continue;
            }

            seenTarget = candidate;
            return true;
        }

        return false;
    }
}

Monte o inimigo com um filho vazio Olho na altura da cabeça (cerca de 1,6 m num humanoide) e arraste para o campo eye. Em Target Mask, só Player; em Obstacle Mask, só Obstacle.

Detalhes do código:

  • OverlapSphereNonAlloc com buffer acha quem está no raio sem criar array novo a cada chamada. A documentação descreve como a versão de OverlapSphere que não gera lixo para o coletor, e avisa que o buffer não cresce: com 8 posições, ele devolve no máximo 8. Para um jogador, sobra. Se o jogo tem aliados e iscas na mesma layer, aumente.
  • Ângulo no plano horizontal. Zerar o Y antes do Vector3.Angle evita que o jogador num andar de cima ou de baixo fique "fora do cone" só pela inclinação. Se o seu jogo tem visão vertical (guarda numa torre), use o vetor completo e um segundo ângulo vertical.
  • targetHeightOffset mira o raio no peito do jogador, não no pé. Raio no pé bate em degrau, mureta e no próprio chão.

Linha de visão com Raycast e LayerMask

O Raycast vai do olho até o ponto do jogador com a distância exata entre os dois. Se bater em qualquer coisa da obstacleMask antes de chegar, a visão está bloqueada.

Três cuidados:

  • A máscara contém só obstáculos. Se a máscara incluísse o jogador, o raio bateria no jogador e você teria que comparar o que atingiu. Com a máscara só de paredes, a lógica fica binária: bateu, não vê.
  • QueryTriggerInteraction.Ignore. Zonas de trigger (área de diálogo, checkpoint) não bloqueiam visão.
  • Vidro, grade e folhagem. Coloque numa layer fora da obstacleMask se o guarda deve ver através. Folhagem densa que esconde é escolha de design: pode bloquear visão e não bloquear tiro.

Para ir além (um raio para a cabeça e outro para o tronco, visibilidade parcial), lance dois ou três raios para pontos diferentes do jogador e conte quantos passaram. Visibilidade de 1 em 3 pode significar "suspeito" em vez de "viu".

Desenhar o cone com Gizmos/Handles

Um cone que você não vê é um cone que você não ajusta. Handles.DrawSolidArc desenha um setor preenchido, mas Handles é do editor, então o código fica protegido com #if UNITY_EDITOR. Acrescente dentro da classe EnemyVision:

#if UNITY_EDITOR
    private void OnDrawGizmosSelected()
    {
        Vector3 origin = eye != null ? eye.position : transform.position;
        Vector3 flatForward = new Vector3(transform.forward.x, 0f, transform.forward.z).normalized;
        Vector3 startDirection = Quaternion.AngleAxis(-viewAngle * 0.5f, Vector3.up) * flatForward;

        UnityEditor.Handles.color = new Color(1f, 0.85f, 0.2f, 0.15f);
        UnityEditor.Handles.DrawSolidArc(origin, Vector3.up, startDirection, viewAngle, viewRadius);

        UnityEditor.Handles.color = new Color(1f, 0.85f, 0.2f, 0.8f);
        UnityEditor.Handles.DrawWireArc(origin, Vector3.up, startDirection, viewAngle, viewRadius);
    }
#endif

O arco começa em metade do ângulo para a esquerda da frente (Quaternion.AngleAxis com ângulo negativo em volta do Y) e varre o ângulo inteiro. Selecione o inimigo e o cone aparece no chão da Scene view. Ajuste viewAngle e viewRadius olhando para a fase: o cone deve cobrir o que o guarda vigia e deixar a sombra por onde o jogador passa.

Montar o cone é satisfatório. Fazer o stealth funcionar como jogo é outra coisa: guardas com rotas que se cruzam, estados de suspeita com feedback claro, animação de olhar em volta, som, UI de alerta e uma fase desenhada em volta disso. É nesse encaixe que muita gente trava sozinha, porque cada sistema parece pronto isolado e quebra quando encontra os outros.

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

Audição: ruído com raio e intensidade

Visão diz onde o jogador está. Audição diz onde algo aconteceu. O guarda que ouve vai até o ponto do som, e o jogador ganha uma ferramenta: jogar uma garrafa para distrair.

O barulho é um evento: quem faz barulho avisa os inimigos próximos. Crie NoiseEmitter.cs:

using UnityEngine;

public static class NoiseEmitter
{
    private static readonly Collider[] Buffer = new Collider[32];

    public static void Emit(Vector3 position, float loudness, LayerMask listenerMask)
    {
        int count = Physics.OverlapSphereNonAlloc(position, loudness, Buffer, listenerMask, QueryTriggerInteraction.Collide);
        for (int i = 0; i < count; i++)
        {
            EnemyAwareness listener = Buffer[i].GetComponentInParent<EnemyAwareness>();
            if (listener != null)
            {
                float distance = Vector3.Distance(position, listener.transform.position);
                float intensity = 1f - Mathf.Clamp01(distance / loudness);
                listener.HearNoise(position, intensity);
            }
        }
    }
}

O loudness é o raio em metros. Andar agachado não chama nada; andar normal, 3 m; correr, 8 m; cair de altura ou quebrar vidro, 15 m. A intensity vai de 1 (colado) a 0 (no limite), e o inimigo pode reagir diferente a barulho fraco e forte. No script de movimento do jogador, chame NoiseEmitter.Emit(transform.position, 8f, enemyMask) a cada passo de corrida (por evento de animação, por exemplo), não a cada frame.

Perder o jogador: memória e última posição vista

Agora o cérebro que junta visão e audição. Crie EnemyAwareness.cs:

using UnityEngine;

[RequireComponent(typeof(EnemyVision))]
public class EnemyAwareness : MonoBehaviour
{
    public enum AwarenessState { Unaware, Suspicious, Alert }

    [SerializeField] private float memoryDuration = 4f;
    [SerializeField] private float suspicionToAlert = 1f;
    [SerializeField] private float suspicionGainPerSecond = 2f;
    [SerializeField] private float noiseSuspicionThreshold = 0.5f;

    private EnemyVision vision;
    private float suspicion;
    private float timeSinceSeen = float.MaxValue;

    public AwarenessState State { get; private set; } = AwarenessState.Unaware;
    public Vector3 LastKnownPosition { get; private set; }

    private void Awake()
    {
        vision = GetComponent<EnemyVision>();
    }

    private void Update()
    {
        if (vision.TrySeeTarget(out Transform target))
        {
            LastKnownPosition = target.position;
            timeSinceSeen = 0f;
            suspicion = Mathf.Min(suspicion + suspicionGainPerSecond * Time.deltaTime, suspicionToAlert);

            State = suspicion >= suspicionToAlert ? AwarenessState.Alert : AwarenessState.Suspicious;
            return;
        }

        timeSinceSeen += Time.deltaTime;

        if (State == AwarenessState.Alert && timeSinceSeen > memoryDuration)
        {
            State = AwarenessState.Suspicious;
        }
        else if (State == AwarenessState.Suspicious && timeSinceSeen > memoryDuration * 2f)
        {
            State = AwarenessState.Unaware;
            suspicion = 0f;
        }
    }

    public void HearNoise(Vector3 position, float intensity)
    {
        if (State == AwarenessState.Alert)
        {
            LastKnownPosition = position;
            return;
        }

        if (intensity >= noiseSuspicionThreshold || State == AwarenessState.Suspicious)
        {
            LastKnownPosition = position;
            State = AwarenessState.Suspicious;
            timeSinceSeen = 0f;
        }
    }
}

Como funciona:

  • Suspeita acumula. Ver o jogador por um instante não dispara o alarme; o medidor sobe com o tempo de exposição. Com suspicionGainPerSecond = 2 e limite 1, são 0,5 segundo de visão contínua para o alerta. Dá ao jogador a chance de sair do cone.
  • LastKnownPosition é para onde o inimigo vai quando perde o jogador. Ele não sabe onde o jogador está agora; sabe onde viu ou ouviu por último. O script de movimento (um NavMeshAgent, por exemplo) lê o estado e o ponto: em Alert persegue, em Suspicious vai até o ponto e olha em volta, em Unaware volta à patrulha.
  • Memória em dois degraus. Depois de memoryDuration sem ver, o Alert cai para Suspicious (procura); depois do dobro, volta a Unaware. É o "deve ter sido o vento" dos jogos de stealth.

Exiba o estado para o jogador: um ícone de interrogação em Suspicious e de exclamação em Alert. Stealth sem feedback é frustração.

Versão 2D com Physics2D.Raycast

Em 2D (top-down ou plataforma), a lógica é a mesma, com APIs de física 2D e a "frente" do inimigo vindo da direção para onde ele olha. Crie EnemyVision2D.cs:

using System.Collections.Generic;
using UnityEngine;

public class EnemyVision2D : MonoBehaviour
{
    [SerializeField] private float viewRadius = 7f;
    [SerializeField, Range(0f, 360f)] private float viewAngle = 80f;
    [SerializeField] private LayerMask targetMask;
    [SerializeField] private LayerMask obstacleMask;

    private readonly List<Collider2D> overlapResults = new List<Collider2D>();
    private ContactFilter2D targetFilter;

    public Vector2 Facing { get; set; } = Vector2.right;

    private void Awake()
    {
        targetFilter = new ContactFilter2D();
        targetFilter.SetLayerMask(targetMask);
        targetFilter.useTriggers = false;
    }

    public bool TrySeeTarget(out Transform seenTarget)
    {
        seenTarget = null;
        Vector2 origin = transform.position;

        int count = Physics2D.OverlapCircle(origin, viewRadius, targetFilter, overlapResults);
        for (int i = 0; i < count; i++)
        {
            Vector2 toTarget = (Vector2)overlapResults[i].bounds.center - origin;
            if (Vector2.Angle(Facing, toTarget) > viewAngle * 0.5f)
            {
                continue;
            }

            RaycastHit2D hit = Physics2D.Raycast(origin, toTarget.normalized, toTarget.magnitude, obstacleMask);
            if (hit.collider != null)
            {
                continue;
            }

            seenTarget = overlapResults[i].transform;
            return true;
        }

        return false;
    }
}

Diferenças para o 3D:

  • Facing é explícito. Em plataforma, atualize com Vector2.right ou Vector2.left quando o sprite vira; em top-down, com a direção do último movimento (rb.linearVelocity.normalized, se não for zero).
  • Physics2D.OverlapCircle com ContactFilter2D e List devolve a quantidade de colliders e reaproveita a lista. A documentação do Unity 6.3 diz que a lista cresce se faltar espaço, então não há limite fixo como no buffer do 3D.
  • Physics2D.Raycast devolve um RaycastHit2D, e o teste é hit.collider != null, não um bool.
  • Mire no centro do collider (bounds.center) para não lançar o raio no pé do sprite.

O EnemyAwareness funciona igual com a versão 2D: troque o tipo do campo de visão (ou crie uma interface comum com TrySeeTarget) e use Physics2D.OverlapCircle no emissor de ruído.

Erros comuns: inimigo vendo através da parede

Vê através da parede. A parede não está na obstacleMask, não tem collider, ou o collider é trigger e o Raycast ignora triggers. Confira a layer e o collider da parede.

Nunca vê o jogador. O jogador não está na targetMask, ou, na versão 2D, o raio sai de dentro do collider do próprio inimigo e bate nele (com Queries Start In Colliders ligado, o padrão da física 2D; a física 3D ignora o collider de onde o raio parte). Coloque o inimigo numa layer fora da obstacleMask.

Cone com o dobro do tamanho. Comparação com viewAngle em vez de viewAngle * 0.5f.

Vê o jogador agachado atrás de mureta. O raio mira no ponto errado. Com o jogador agachado, reduza o targetHeightOffset para a altura real da cabeça dele.

Detecção instantânea e injusta. Sem acúmulo de suspeita. Use o medidor de exposição.

Inimigo 2D "olha" para o lado errado. Facing não foi atualizado quando o sprite virou.

Próximo passo

Monte uma sala com três caixas, um guarda com EnemyVision e EnemyAwareness, e faça o jogador correr (barulho alto) e andar (sem barulho). Exiba o estado acima da cabeça do guarda com um texto. Quando você conseguir passar por ele usando as caixas e o silêncio, o sistema está pronto para virar fase. O próximo passo é a linha de visão para outros usos (tiro, interação), assunto do post de Raycast na Unity.

Perguntas frequentes

Como fazer o campo de visão de um inimigo na Unity?

Faça três checagens em ordem: o jogador está dentro do raio de visão, o ângulo entre a frente do inimigo e a direção do jogador é menor que metade do ângulo do cone, e um Raycast do olho do inimigo até o jogador não bate em parede. Só se as três passarem, o inimigo vê.

Por que o inimigo vê o jogador através da parede?

Falta a checagem de linha de visão, ou o Raycast usa uma LayerMask que não inclui a layer das paredes. Confira também se a parede tem collider e se ele não é trigger. Lance o raio da altura dos olhos e com a máscara só de obstáculos.

Como fazer o inimigo ouvir o jogador na Unity?

Quando o jogador faz barulho (corre, cai, quebra algo), procure inimigos num raio proporcional à intensidade com Physics.OverlapSphere ou a versão com buffer e avise cada um da posição do som. O inimigo vai investigar o ponto, sem saber onde o jogador está agora.

Qual a diferença entre Vector3.Angle e produto escalar para o cone de visão?

Vector3.Angle devolve o ângulo em graus, fácil de ler e comparar com o ângulo do cone. O produto escalar (Vector3.Dot) de vetores normalizados devolve o cosseno, que é um pouco mais barato. Para poucos inimigos, use Angle pela clareza.