Voltar para o Blog
Quest Log

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

Inimigo robótico patrulhando um corredor com cone de visão enquanto o jogador se esconde

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.

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

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.