IA de Inimigo em Jogos: Perseguir, Patrulhar e Atacar no Godot 4

Aprenda a programar IA de inimigo para jogo no Godot 4: máquina de estados, patrulha por waypoints, detecção do player com raycast, perseguição e ataque.
IA de Inimigo em Jogos: Perseguir, Patrulhar e Atacar no Godot 4
Uma boa IA de inimigo em um jogo não precisa ser inteligente de verdade. Ela precisa ser legível: o jogador tem que entender o que o inimigo está fazendo, prever o que ele vai fazer e sentir que foi culpa dele quando for pego. O guarda de Metal Gear que anda em rotina fixa e grita quando te vê é mais divertido que qualquer rede neural, porque o jogador consegue ler e planejar em volta dele.
A boa notícia: esse tipo de inimigo cabe em três estados (patrulhar, perseguir, atacar) e um punhado de regras de transição entre eles. É o padrão máquina de estados finitos (FSM), e é a base de praticamente todo inimigo de ação que você já enfrentou.
Nesse tutorial eu monto um inimigo completo no Godot 4, em GDScript, visão top-down pra manter o foco na IA e não na gravidade. A mesma estrutura funciona em plataforma e em 3D, só muda o código de movimento. Todo código roda como está.
A máquina de estados: o esqueleto da IA de inimigo
Antes de qualquer detecção ou movimento, vem a estrutura. Uma máquina de estados é só isso: o inimigo está em exatamente um estado por vez, cada estado tem seu comportamento, e existem regras claras pra trocar de um pro outro.
O erro clássico de iniciante é não usar estado nenhum: vai empilhando if dentro de _physics_process até ninguém mais saber por que o inimigo treme entre andar e atacar. Com estados, cada comportamento vive isolado e as transições ficam explícitas.
A estrutura de nodes do inimigo:
Inimigo (CharacterBody2D)
├── CollisionShape2D
├── Sprite2D
├── RayCast2D # linha de visão
└── AreaDeVisao (Area2D) # raio de percepção
└── CollisionShape2D # um círculo grande
E o esqueleto do script:
extends CharacterBody2D
enum Estado { PATRULHAR, PERSEGUIR, ATACAR }
var estado: Estado = Estado.PATRULHAR
var player: Node2D = null
func _physics_process(delta):
match estado:
Estado.PATRULHAR:
patrulhar(delta)
Estado.PERSEGUIR:
perseguir(delta)
Estado.ATACAR:
atacar(delta)
move_and_slide()
func mudar_estado(novo: Estado):
if novo == estado:
return
estado = novo
# Ponto único de troca: ótimo lugar pra disparar
# animação, som de alerta, exclamação na cabeça etc.
O match despacha pro comportamento do estado atual, e o mudar_estado() centraliza toda transição. Esse funil único parece burocracia agora, mas é ele que deixa você plugar feedback depois (o "!" em cima da cabeça do guarda) sem caçar transição espalhada pelo código.
Pra três estados, esse enum com match é o tamanho certo de solução. Existe um padrão mais robusto, com um node por estado, que vale a pena quando o inimigo passa de uns cinco ou seis comportamentos. Pra começar, isso aqui resolve e é fácil de debugar.
Patrulha: o inimigo precisa parecer vivo
Inimigo parado esperando o player é alvo de tiro. Patrulha é o que vende a ilusão de que o mundo existe sem o jogador, e a implementação mais útil é a mais simples: uma lista de pontos que o inimigo visita em loop.
No editor, crie nodes Marker2D espalhados pela fase (filhos da fase, não do inimigo) e arraste todos pro array exportado:
@export var pontos_patrulha: Array[Marker2D] = []
@export var velocidade_patrulha := 60.0
@export var tempo_de_espera := 1.5
var indice_patrulha := 0
var espera := 0.0
func patrulhar(delta):
if pontos_patrulha.is_empty():
velocity = Vector2.ZERO
return
# Pausa no waypoint antes de seguir pro próximo.
if espera > 0.0:
espera -= delta
velocity = Vector2.ZERO
return
var alvo = pontos_patrulha[indice_patrulha].global_position
if global_position.distance_to(alvo) < 8.0:
indice_patrulha = (indice_patrulha + 1) % pontos_patrulha.size()
espera = tempo_de_espera
return
velocity = global_position.direction_to(alvo) * velocidade_patrulha
Dois detalhes que fazem diferença real aqui:
A tolerância de chegada. O < 8.0 existe porque o inimigo nunca vai parar exatamente em cima do ponto. Sem tolerância, ele orbita o waypoint pra sempre, vibrando. Se o seu inimigo for rápido, aumente o valor.
A espera no waypoint. O tempo_de_espera é a diferença entre um robô de esteira e um guarda. Parar um segundo e meio em cada ponto cria a janela que o jogador usa pra passar, e stealth inteiro é desenhado em cima dessas janelas.
A patrulha com direction_to anda em linha reta, então funciona em área aberta. Se a sua fase tem labirinto de paredes, troque o movimento por NavigationAgent2D, que calcula o caminho pelo navmesh. A máquina de estados não muda nada, só a parte que gera o velocity.
Detecção: o player entrou no campo de visão?
Detecção boa tem duas camadas, e pular a segunda é o bug mais comum em IA de inimigo: o guarda que te enxerga através da parede.
Camada 1: alcance. A AreaDeVisao (Area2D com um círculo grande) responde "o player está perto o suficiente?". Conecte os sinais body_entered e body_exited dela no script do inimigo:
func _on_area_de_visao_body_entered(body):
if body.is_in_group("player"):
player = body
func _on_area_de_visao_body_exited(body):
if body == player:
player = null
Coloque o player no grupo player (painel Node > Groups) e confira a collision mask da Area2D pra detectar a camada dele. Guardar a referência em player evita ficar fazendo busca na árvore todo frame.
Camada 2: linha de visão. Estar no alcance não significa estar visível. O RayCast2D responde a pergunta que falta: tem parede no caminho?
@onready var raycast: RayCast2D = $RayCast2D
func enxerga_player() -> bool:
if player == null:
return false
raycast.target_position = to_local(player.global_position)
raycast.force_raycast_update()
return raycast.get_collider() == player
A lógica: aponto o ray pro player e atualizo na hora com force_raycast_update() (sem isso, o resultado só chega no próximo passo de física). Se a primeira coisa que o ray acerta é o próprio player, a linha de visão está limpa. Se acertou outra coisa, tem obstáculo no meio. A collision mask do RayCast2D precisa incluir a camada das paredes e a do player, senão o ray atravessa parede e o teste mente.
Com as duas camadas prontas, a transição de patrulha pra perseguição é uma linha no fim de patrulhar():
if enxerga_player():
mudar_estado(Estado.PERSEGUIR)
Se quiser um cone de visão em vez de círculo (o guarda não tem olho na nuca), adicione um teste de ângulo antes do raycast: compare a direção até o player com a direção que o inimigo está encarando usando dot(), e só prossiga se o resultado passar de um limiar. É uma checagem barata e muda completamente o jogo de stealth.
Perseguir e atacar: a pressão e o clímax
Perseguição é o estado mais simples dos três no código, e o mais sensível no tuning:
@export var velocidade_perseguicao := 110.0
@export var alcance_de_ataque := 40.0
@export var tempo_de_memoria := 2.0
var memoria := 0.0
var ultima_posicao_vista := Vector2.ZERO
func perseguir(delta):
if player and enxerga_player():
memoria = tempo_de_memoria
ultima_posicao_vista = player.global_position
else:
# Perdeu de vista: segue pra última posição conhecida.
memoria -= delta
if memoria <= 0.0:
mudar_estado(Estado.PATRULHAR)
return
if player and global_position.distance_to(player.global_position) < alcance_de_ataque:
mudar_estado(Estado.ATACAR)
return
velocity = global_position.direction_to(ultima_posicao_vista) * velocidade_perseguicao
Três decisões de design escondidas nessas linhas:
Velocidade de perseguição maior que a do player ou menor? Se for maior, todo encontro termina em combate. Se for um pouco menor, fugir vira opção real e o jogador escolhe a briga. Não é detalhe técnico, é o tipo de jogo que você está fazendo.
A memória. Sem o tempo_de_memoria, o inimigo esquece o player no exato frame em que ele dobra a esquina, e dá pra "desligar" o guarda piscando atrás de qualquer pilastra. Com memória, ele segue até a última posição vista e só desiste depois. Dois segundos já mudam a sensação de "script burro" pra "ele está me procurando".
Perseguir a última posição vista, não a atual. Repare que o velocity mira ultima_posicao_vista. Enquanto enxerga, ela é atualizada todo frame e o efeito é o mesmo. Quando perde de vista, o inimigo vai até onde o player estava, o que é exatamente o comportamento que parece natural.
O ataque fecha o ciclo. A regra de ouro: o estado de atacar não persegue. Ele para, executa e reavalia:
@export var intervalo_de_ataque := 1.0
@export var dano := 10
var recarga := 0.0
func atacar(delta):
velocity = Vector2.ZERO
recarga -= delta
if player == null or global_position.distance_to(player.global_position) > alcance_de_ataque:
mudar_estado(Estado.PERSEGUIR)
return
if recarga <= 0.0:
recarga = intervalo_de_ataque
if player.has_method("receber_dano"):
player.receber_dano(dano)
O intervalo_de_ataque é o respiro do jogador. Inimigo que causa dano todo frame em contato é frustração, não desafio. Um golpe por segundo dá tempo de reagir, recuar, contra-atacar. E o has_method evita crash se o ataque acertar algo que não tem vida.
Em jogo de verdade, esse receber_dano direto vira uma animação com janela de acerto: o inimigo arma o golpe (telegraph), o jogador tem uma fração de segundo pra esquivar, e só então o dano sai. A estrutura é a mesma, o dano só passa a ser disparado por um sinal da animação em vez de imediato.
Tuning: onde a IA vira game design
Com a máquina rodando, o trabalho muda de programar pra ajustar. Os números exportados são o painel de personalidade do inimigo:
- Raio de visão grande + memória longa = caçador implacável, bom pra terror
- Raio curto + espera longa nos waypoints = guarda distraído, bom pra stealth
- Perseguição mais lenta que o player + ataque forte = tanque que você contorna
- Perseguição rápida + dano baixo = mosquito que pressiona em grupo
Pra debugar transição, um print dentro de mudar_estado() mostrando o estado novo já revela quase tudo: estado oscilando entre dois valores todo frame indica condição de entrada e saída se sobrepondo (quase sempre é a distância de ataque igual à distância de saída, resolva com uma folga entre as duas). E Debug > Visible Collision Shapes mostra a área de visão e o raycast em tempo real durante o jogo.
Fechando
Um inimigo que patrulha, detecta, persegue e ataca cabe em uma máquina de estados de três valores, dois nodes de percepção e meia dúzia de números exportados. Nada aqui é truque avançado, e é exatamente por isso que esse padrão sobrevive década após década: é fácil de ler, fácil de estender e fácil de ajustar.
Se quiser fixar, monta esse inimigo numa cena vazia e vai testando um estado por vez: primeiro a patrulha sozinha, depois a detecção com um print, depois a perseguição, por último o ataque. Quando estiver rodando, crie variações só mexendo nos exports, sem tocar no código. No dia em que três cópias do mesmo script parecerem inimigos diferentes, você entendeu o que IA de inimigo realmente é: comportamento simples com personalidade nos números.


