Voltar para o Blog
Quest Log

Como Fazer Jogo Stealth: Cone de Visão, Suspeita e Ruído no Godot 4

Guarda patrulhando um corredor escuro com um cone de visão amarelo projetado no chão enquanto o jogador se esconde atrás de uma caixa

Aprenda como fazer jogo stealth no Godot 4: cone de visão com Area2D e RayCast2D, medidor de suspeita, ruído ao correr e esconderijos em GDScript tipado.

Como Fazer Jogo Stealth: Cone de Visão, Suspeita e Ruído no Godot 4

Se você quer aprender como fazer jogo stealth, esqueça por enquanto inimigo esperto, iluminação dinâmica e sistema de câmera. Stealth é um jogo de informação: o jogador precisa ler o que cada inimigo vê e ouve, naquele exato momento, pra decidir se atravessa o corredor agora ou espera mais dois segundos. Em vinte e poucos anos fazendo e destrinchando jogos, eu resumo o gênero numa regra só: o estado do inimigo tem que estar sempre visível. Cone desenhado no chão, ícone de alerta na cabeça, som de suspeita. Quando o jogador é pego sem entender o porquê, ele não culpa a si mesmo, culpa o jogo. E fecha o jogo.

Neste tutorial eu monto o núcleo de um stealth 2D no Godot 4: cone de visão que o jogador enxerga na tela, raycast que respeita parede, medidor de suspeita que sobe aos poucos, três estados de inimigo, sistema de ruído pra quem corre e esconderijo pra quem erra. Tudo em GDScript com tipagem estática, em blocos que você pode colar no seu projeto.

Stealth funciona na cabeça do jogador

Antes do código, o princípio de design que sustenta tudo. Num jogo de ação, o desafio está nos dedos. Num jogo stealth, o desafio está no planejamento: observar a rotina do guarda, identificar a janela, executar. Isso só funciona se o jogo entrega informação honesta o tempo todo.

Mark of the Ninja é a aula definitiva disso: cada barulho vira um círculo visível, cada cone de visão está desenhado, cada estado de inimigo tem ícone. O jogador nunca adivinha, ele lê. Metal Gear faz o mesmo com o radar e a exclamação sonora. O que esses jogos entenderam é que esconder informação do jogador não cria tensão, cria frustração. A tensão vem de o jogador saber exatamente o risco que está correndo.

A segunda decisão de design importante: detecção gradual em vez de binária. Se pisar no cone significa game over instantâneo, cada erro pequeno custa a fase inteira e o jogo vira tentativa e erro decorado. Com um medidor de suspeita que sobe enquanto você está exposto, aparece a mecânica mais gostosa do gênero: ser visto de relance, ver o guarda desconfiar e sair andando na direção errada enquanto seu coração acelera.

Guarde essas duas regras: estado do inimigo sempre visível, detecção sempre gradual. Todo o código abaixo existe pra servir a elas.

O cone de visão 2D que todo jogo stealth precisa

A detecção visual tem dois passos, e separar os dois é o que faz o sistema funcionar:

  1. Candidato: uma Area2D com CollisionPolygon2D em formato de cone detecta que o player entrou no campo de visão em potencial.
  2. Confirmação: um raycast verifica se existe linha de visão direta. Parede no caminho, o inimigo não vê nada.

A estrutura de nodes do inimigo:

Inimigo (CharacterBody2D)
├── CollisionShape2D
├── Sprite2D
├── RayCast2D
└── ConeDeVisao (Area2D)
    └── CollisionPolygon2D

O script do cone gera o polígono por código (assim alcance e ângulo viram números ajustáveis no Inspector) e desenha o mesmo polígono na tela com _draw(). Um polígono só, usado pra colisão e pra visual, nunca dessincroniza:

# cone_de_visao.gd
class_name ConeDeVisao
extends Area2D

@export var alcance: float = 220.0
@export var meio_angulo_graus: float = 35.0

var cor_atual: Color = Color(1.0, 1.0, 0.0, 0.25)

@onready var colisor: CollisionPolygon2D = $CollisionPolygon2D

func _ready() -> void:
    colisor.polygon = gerar_cone()

func gerar_cone() -> PackedVector2Array:
    var pontos: PackedVector2Array = PackedVector2Array([Vector2.ZERO])
    for i: int in range(11):
        var t: float = float(i) / 10.0
        var angulo: float = deg_to_rad(lerpf(-meio_angulo_graus, meio_angulo_graus, t))
        pontos.append(Vector2.RIGHT.rotated(angulo) * alcance)
    return pontos

func _draw() -> void:
    draw_colored_polygon(colisor.polygon, cor_atual)

func mudar_cor(nova: Color) -> void:
    cor_atual = nova
    queue_redraw()

O gerar_cone() cria um leque: o ponto zero é o olho do inimigo e os onze pontos seguintes desenham o arco. O _draw() pinta esse polígono com draw_colored_polygon(), e o mudar_cor() chama queue_redraw() pra atualizar a tela quando o estado do inimigo mudar. Amarelo transparente na patrulha, laranja quando suspeito, vermelho no alerta. É a primeira regra de design implementada em quinze linhas: o jogador olha pra tela e sabe o que cada inimigo está sentindo.

Como o cone é filho do inimigo, girar o inimigo gira o cone junto, colisão e desenho de uma vez.

Parede existe: confirmando a linha de visão

A Area2D sozinha tem um defeito fatal: ela detecta o player mesmo com uma parede inteira entre os dois. Todo tutorial que para no primeiro passo produz guarda com visão de raio X, e o jogador percebe na hora. A correção é o segundo passo, o raycast. Se você nunca usou, eu explico o node em detalhe no guia de RayCast2D no Godot; aqui vai direto o uso:

# inimigo.gd (parte 1: detecção)
extends CharacterBody2D

@onready var cone: ConeDeVisao = $ConeDeVisao
@onready var raio_visao: RayCast2D = $RayCast2D

var candidato: Player = null

func _on_cone_de_visao_body_entered(body: Node2D) -> void:
    if body is Player:
        candidato = body as Player

func _on_cone_de_visao_body_exited(body: Node2D) -> void:
    if body == candidato:
        candidato = null

func ve_o_player() -> bool:
    if candidato == null:
        return false
    if candidato.escondido and estado != Estado.ALERTA:
        return false
    raio_visao.target_position = to_local(candidato.global_position)
    raio_visao.force_raycast_update()
    return raio_visao.get_collider() == candidato

Os sinais body_entered e body_exited da Area2D mantêm a referência do candidato. O ve_o_player() aponta o raycast pro player (target_position é local, por isso o to_local()), força a atualização com force_raycast_update() e pergunta o que o raio acertou primeiro. Se foi uma parede, retorna a parede e a função responde false. Configure a máscara de colisão do RayCast2D pra enxergar as camadas de parede e de player, e nada mais.

Duas notas: a checagem de escondido e o estado vêm das seções seguintes, deixa eles aí. E se você precisar de vários raios por frame (vários inimigos mirando pontos diferentes do corpo do player), o caminho é consultar o espaço físico direto com PhysicsDirectSpaceState2D.intersect_ray(), que dispensa o node. Pra um raio por inimigo, o RayCast2D é mais simples e resolve.

Próximo nível
Quer aprender isso na prática?

No CursoGame.Dev você sai dos tutoriais soltos e constrói jogos publicáveis, com trilha progressiva, quests práticas e feedback real.

Conhecer a plataforma
+500 alunos4.9/5Garantia 7 dias

Patrulha, suspeito, alerta: os três estados

Detecção binária (viu, perdeu) produz inimigo histérico. O que a gente quer é uma escada de três degraus: patrulha (rotina normal), suspeito (viu algo de relance, vai até lá investigar) e alerta (confirmou, persegue). Quem sobe e desce essa escada é o medidor de suspeita, um float que acumula com delta enquanto o player está visível:

# inimigo.gd (parte 2: estados e suspeita)
enum Estado { PATRULHA, SUSPEITO, ALERTA }

const GANHO_SUSPEITA: float = 0.8
const PERDA_SUSPEITA: float = 0.4
const LIMIAR_SUSPEITO: float = 0.3

var estado: Estado = Estado.PATRULHA
var suspeita: float = 0.0
var ultima_posicao_vista: Vector2 = Vector2.ZERO

func _physics_process(delta: float) -> void:
    if ve_o_player():
        suspeita = minf(suspeita + GANHO_SUSPEITA * delta, 1.0)
        ultima_posicao_vista = candidato.global_position
    else:
        suspeita = maxf(suspeita - PERDA_SUSPEITA * delta, 0.0)

    match estado:
        Estado.PATRULHA:
            if suspeita >= LIMIAR_SUSPEITO:
                mudar_estado(Estado.SUSPEITO)
        Estado.SUSPEITO:
            if suspeita >= 1.0:
                mudar_estado(Estado.ALERTA)
            elif suspeita <= 0.0:
                mudar_estado(Estado.PATRULHA)
        Estado.ALERTA:
            if suspeita <= 0.0:
                mudar_estado(Estado.SUSPEITO)

func mudar_estado(novo: Estado) -> void:
    if novo == estado:
        return
    estado = novo
    match estado:
        Estado.PATRULHA:
            cone.mudar_cor(Color(1.0, 1.0, 0.0, 0.25))
        Estado.SUSPEITO:
            cone.mudar_cor(Color(1.0, 0.6, 0.0, 0.35))
        Estado.ALERTA:
            cone.mudar_cor(Color(1.0, 0.2, 0.2, 0.4))

Repare nos números: a suspeita sobe duas vezes mais rápido do que desce. Com ganho de 0.8 por segundo, o player tem pouco mais de um segundo de exposição total antes do alerta, e sair do cone não zera nada na hora. Errou, a dívida fica. Esses três valores (GANHO_SUSPEITA, PERDA_SUSPEITA, LIMIAR_SUSPEITO) são o coração do balanceamento do seu stealth inteiro; exponha os três com @export e ajuste testando.

O mudar_estado() é o funil único de transição, e é ali que o feedback acontece: cor do cone, e depois som e ícone. Esse padrão de enum com match eu destrincho no artigo sobre state machine no Godot. E o comportamento dentro de cada estado (andar entre waypoints na patrulha, ir até ultima_posicao_vista quando suspeito, correr atrás do player no alerta) é exatamente o que eu construo passo a passo no tutorial de IA de inimigo que persegue e patrulha; os dois sistemas se encaixam sem adaptação.

Ruído: correr tem preço

Visão sozinha deixa o stealth raso, porque só existe um recurso pra administrar (a posição). Audição cria a segunda camada: agora atravessar uma sala rápido tem custo. A implementação mais simples que funciona é um evento de som com posição e raio:

# player.gd
class_name Player
extends CharacterBody2D

signal fez_barulho(posicao: Vector2, raio: float)

var escondido: bool = false

const VEL_ANDAR: float = 120.0
const VEL_CORRER: float = 220.0
const RAIO_BARULHO: float = 260.0

func _physics_process(_delta: float) -> void:
    var direcao: Vector2 = Input.get_vector("esquerda", "direita", "cima", "baixo")
    var correndo: bool = Input.is_action_pressed("correr")
    velocity = direcao * (VEL_CORRER if correndo else VEL_ANDAR)
    move_and_slide()
    if correndo and direcao != Vector2.ZERO:
        fez_barulho.emit(global_position, RAIO_BARULHO)


# inimigo.gd (parte 3: audição)
var ultima_posicao_ouvida: Vector2 = Vector2.ZERO

func _on_player_fez_barulho(posicao: Vector2, raio: float) -> void:
    if global_position.distance_to(posicao) > raio:
        return
    ultima_posicao_ouvida = posicao
    suspeita = maxf(suspeita, LIMIAR_SUSPEITO)
    if estado == Estado.PATRULHA:
        mudar_estado(Estado.SUSPEITO)

O player emite o sinal fez_barulho enquanto corre, e cada inimigo conectado compara a distância. Dentro do raio, ele guarda a posição ouvida, ganha suspeita suficiente pra cruzar o limiar e vai investigar o ponto. Detalhe importante: ele investiga onde o som nasceu, não onde o player está agora. Isso abre a jogada clássica do gênero, correr de propósito num canto pra atrair o guarda e passar pelo outro lado.

Em produção, ligue a emissão ao frame do passo na animação em vez de emitir todo tick de física, e desenhe um círculo que pulsa no chão quando o barulho acontece. De novo a primeira regra: se o jogo tem um sistema de som, o jogador precisa ver o som.

Esconderijo: a válvula de escape

Stealth sem esconderijo é corredor da morte. O armário do Outlast e a moita do Assassins Creed cumprem a mesma função de design: dar ao jogador um botão de reset da tensão. A implementação é uma Area2D cobrindo a zona segura:

# esconderijo.gd
extends Area2D

func _on_body_entered(body: Node2D) -> void:
    if body is Player:
        (body as Player).escondido = true

func _on_body_exited(body: Node2D) -> void:
    if body is Player:
        (body as Player).escondido = false

A flag escondido já está sendo checada lá no ve_o_player(), com uma exceção deliberada: estado != Estado.ALERTA. Traduzindo a regra: o esconderijo torna o player invisível pro guarda que não o viu entrar, mas o guarda que estava perseguindo e viu você pular na moita não esquece magicamente. Sem essa exceção, o esconderijo vira botão de vitória e mata a tensão do jogo; com ela, esconder-se é uma aposta que você precisa fazer antes de ser visto.

Feche o loop e teste a leitura

Recapitulando o sistema completo: o cone detecta o candidato, o raycast confirma respeitando parede, a suspeita acumula com o tempo de exposição, a escada de estados reage (e muda a cor do cone na tela), correr emite som que puxa inimigo pra investigar, e o esconderijo perdoa quem entrou sem ser visto.

O teste de qualidade não é técnico, é de leitura. Chame alguém que nunca viu o projeto, deixe jogar dois minutos e pergunte: você sabia por que foi pego? Se a resposta for sim ("eu corri perto dele", "encostei no cone vermelho"), seu stealth funciona, e agora é produção de conteúdo: fases, rotinas de patrulha variadas, talvez um medidor de suspeita visível sobre a cabeça do guarda. Se a resposta for não, o problema quase sempre é feedback faltando, não código errado. Volte na regra número um: o estado do inimigo tem que estar sempre visível.

Stealth é um dos gêneros mais generosos pra dev solo: arte simples funciona, não precisa de combate elaborado, e cada fase nova reusa os mesmos três scripts. O que você constrói uma vez, o jogo inteiro aproveita.

Perguntas frequentes

Como fazer cone de visão no Godot?

Use uma Area2D com um CollisionPolygon2D em formato de cone pra detectar candidatos e um RayCast2D pra confirmar que nenhuma parede bloqueia a linha de visão. Desenhe o mesmo polígono com draw_colored_polygon() dentro do _draw() pra que o jogador enxergue o campo de visão do inimigo em tempo real.

Preciso de RayCast2D se a Area2D já detecta o player?

Precisa. A Area2D só informa que o player está dentro do formato do cone, mas não sabe se existe uma parede no caminho. O raycast confirma a linha de visão direta; sem ele, o inimigo enxerga através de obstáculos e o jogador se sente trapaceado.

Como fazer o inimigo ouvir o jogador?

Quando o player corre, emita um sinal com a posição e um raio de alcance do barulho. Cada inimigo compara a própria distância até essa posição: se estiver dentro do raio, guarda a última posição ouvida e entra no estado suspeito pra investigar o local.

Como funciona a detecção em jogos stealth?

Os bons jogos stealth usam detecção gradual em vez de binária: um medidor de suspeita sobe enquanto o jogador está no campo de visão e desce quando ele sai. Isso cria a janela de quase fui visto que define o gênero e dá ao jogador tempo de reagir ao próprio erro.

Stealth é um bom gênero pra iniciante?

Sim, desde que o escopo fique pequeno: um cone de visão, três estados de inimigo e meia dúzia de fases já formam um jogo completo. O gênero perdoa arte simples e dispensa combate elaborado, porque a tensão vem da informação, não da ação.