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

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:
- Candidato: uma
Area2DcomCollisionPolygon2Dem formato de cone detecta que o player entrou no campo de visão em potencial. - 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.
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.


