IA Para Jogos no Godot: Pathfinding, Behavior Trees e State Machines

IA para jogos no Godot na pratica: pathfinding com NavigationServer, behavior trees e state machines em GDScript, com codigo que voce roda e debuga.
IA Para Jogos no Godot: Pathfinding, Behavior Trees e State Machines
Um inimigo que fica parado esperando você chegar perto não é IA. É um obstáculo com sprite. A diferença entre os dois é o que vou te mostrar aqui: como fazer um NPC perseguir, patrulhar, perder o jogador de vista e voltar a procurar, tudo com código que você consegue ler e debugar.
Uma coisa que demorei anos pra aceitar: IA de jogo quase nunca precisa ser inteligente de verdade. Ela precisa parecer inteligente. São coisas diferentes, e confundir as duas é o jeito mais rápido de travar num projeto que nunca sai do papel. A maioria dos inimigos que você respeita em jogos comerciais roda em cima de uma máquina de estados de umas cinco linhas de lógica de verdade.
Antes de seguir, vale tirar uma confusão comum do caminho: aqui a gente fala de IA que vive dentro do jogo, controlando NPC e inimigo. Isso é diferente de usar IA generativa para criar o jogo, gerar arte, código ou texto na fase de produção, que é outro assunto.
O Godot ajuda bastante aqui. Tem NavigationServer nativo pra pathfinding, e dá pra montar máquina de estado e behavior tree em GDScript puro, sem instalar nada. Neste tutorial a gente vai do básico (máquina de estados) até decision making com utility AI, sempre com código que roda. Pode copiar, colar e quebrar à vontade. Se quiser ver primeiro o padrão isolado, vale o guia de state machine no Godot e o de inimigo que persegue e patrulha.
Game AI não é a IA da faculdade
Se você já leu alguma coisa sobre redes neurais e machine learning achando que ia usar isso pra fazer inimigo de jogo, pode esquecer. Game AI é outra disciplina. O objetivo não é resolver um problema da forma ótima, é criar a ilusão de comportamento.
O que muda na prática:
- IA acadêmica quer a resposta certa. Game AI quer a resposta divertida.
- IA acadêmica busca o desempenho máximo. Game AI busca o inimigo que perde de um jeito justo.
- IA acadêmica é avaliada por métrica. Game AI é avaliada por quem está jogando se divertir.
O que faz uma IA boa de jogo
Não é uma IA imbatível. Inimigo que sempre vence não é desafio, é parede. Uma IA boa precisa ser:
- Previsível o suficiente pro jogador entender a regra e bolar uma resposta.
- Imprevisível o suficiente pra não virar decoreba depois de duas tentativas.
- Justa. Nada de inimigo que enxerga você através da parede sem motivo. Se ele trapaceia, o jogador percebe e odeia.
- Escalável. Mesma lógica, dificuldade ajustável por parâmetro.
As técnicas que a gente vai usar
- State Machines: o comportamento muda conforme o estado (parado, perseguindo, atacando).
- Pathfinding: ir de A até B desviando de obstáculo, com o
NavigationServer. - Behavior Trees: árvore de decisão hierárquica, boa quando a lógica cresce.
- Utility AI: cada ação ganha uma nota, a IA escolhe a de maior nota.
- Steering Behaviors: movimento orgânico (fugir, perseguir, vagar).
Não precisa de todas ao mesmo tempo. Comece pela máquina de estados, que resolve 80% dos casos, e só adicione o resto quando sentir falta.
State Machines: a fundação de tudo
Máquina de estados finita (FSM) é o padrão mais básico de game AI, e provavelmente o único que você vai usar em 80% dos inimigos que fizer. Domine isso primeiro.
A ideia
A entidade está sempre em exatamente um estado. O estado define o que ela faz agora. As transições levam de um estado pra outro quando uma condição é satisfeita.
Um inimigo simples vive assim:
PARADO -> (avistou o player) -> PERSEGUINDO -> (em alcance) -> ATACANDO
ATACANDO -> (player fugiu) -> PERSEGUINDO -> (player longe demais) -> PARADO
Implementação básica no Godot
A estrutura é sempre a mesma: um match no estado atual decide o comportamento do frame, e uma função separada cuida das transições. Separar essas duas coisas é o que mantém o código legível quando você tiver dez estados.
extends CharacterBody2D
enum State { IDLE, PATROL, CHASE, ATTACK }
@export var detection_range := 200.0
@export var attack_range := 50.0
@export var chase_speed := 150.0
var current_state := State.IDLE
var player: Node2D = null
func _physics_process(delta: float) -> void:
match current_state:
State.IDLE:
state_idle(delta)
State.PATROL:
state_patrol(delta)
State.CHASE:
state_chase(delta)
State.ATTACK:
state_attack(delta)
check_transitions()
func state_idle(delta: float) -> void:
velocity = velocity.move_toward(Vector2.ZERO, 100.0 * delta)
move_and_slide()
func state_patrol(_delta: float) -> void:
# Aqui entra sua lógica de patrulha (waypoints, andar aleatório, etc.)
pass
func state_chase(_delta: float) -> void:
if player == null:
return
var direction := (player.global_position - global_position).normalized()
velocity = direction * chase_speed
move_and_slide()
func state_attack(_delta: float) -> void:
if player == null:
return
look_at(player.global_position)
# Aqui dispara animação de ataque e aplica dano
func check_transitions() -> void:
if player == null:
return
var dist := global_position.distance_to(player.global_position)
match current_state:
State.IDLE:
if dist < detection_range:
transition_to(State.CHASE)
State.CHASE:
# O multiplicador 1.5 cria histerese: o inimigo só
# desiste quando você está bem mais longe do que o
# alcance que o fez começar a perseguir. Sem isso, ele
# fica entrando e saindo do estado na borda do alcance.
if dist > detection_range * 1.5:
transition_to(State.IDLE)
elif dist < attack_range:
transition_to(State.ATTACK)
State.ATTACK:
if dist > attack_range * 1.2:
transition_to(State.CHASE)
func transition_to(new_state: State) -> void:
current_state = new_state
# Bom lugar pra disparar a animação de entrada do novo estado
# ou parar a do estado anterior.
O detalhe que mais gente esquece é a histerese, aquele * 1.5 no comentário. Se o inimigo começa a perseguir a 200 pixels e desiste a 200 pixels, ele vai tremer entre os dois estados quando você ficar parado bem na linha. Dar uma folga entre "começa" e "para" resolve isso e é o tipo de bug que só aparece quando alguém testa de verdade.
Quando a coisa cresce: estados dentro de estados
Pra inimigo de chefe, uma única lista de estados vira uma sopa rápido. A saída é hierarquia: um estado pai COMBATE que por dentro tem CORPO_A_CORPO, DISTÂNCIA, DEFENDENDO. Você guarda dois enums, um pro estado principal e outro pro subestado, e roda dois match aninhados. A regra de transição do subestado só importa enquanto o estado pai for COMBATE. Não complique antes de precisar: a maioria dos inimigos vive feliz com uma FSM plana.
Pathfinding com NavigationServer
Perseguir em linha reta funciona em mapa vazio. No primeiro labirinto o inimigo vai grudar na parede. É aí que entra pathfinding: calcular um caminho que desvia dos obstáculos. Se você usa grid (tabuleiro, tower defense), o caminho mais direto é o pathfinding com AStarGrid2D e Navigation2D.
Montando a navegação 2D
A hierarquia de cena fica mais ou menos assim:
World (Node2D)
├── NavigationRegion2D (o polígono da área onde dá pra andar)
├── Obstacles (StaticBody2D com colisão)
├── Player (CharacterBody2D)
└── Enemy (CharacterBody2D com a IA)
No inspetor do NavigationRegion2D você cria um NavigationPolygon e desenha a região andável com o editor de polígono. O Godot gera o navmesh recortando as colisões automaticamente. Se você mudar a geometria depois, lembre de fazer o bake de novo, senão o caminho calculado vai ignorar a mudança.
Usando o caminho na IA
Pedir um caminho novo todo frame é desperdício. Um inimigo não precisa recalcular a rota 60 vezes por segundo, o jogador não anda tão rápido assim. Use um Timer pra atualizar o caminho a cada meio segundo. Isso é mais limpo e mais previsível do que checar o relógio dentro do _physics_process.
extends CharacterBody2D
@export var speed := 200.0
var player: Node2D = null
var path: PackedVector2Array = []
var path_index := 0
@onready var repath_timer := $RepathTimer # um Timer de 0.5s, autostart, em loop
func _ready() -> void:
player = get_tree().get_first_node_in_group("player")
repath_timer.timeout.connect(update_path_to_player)
func _physics_process(_delta: float) -> void:
if current_state == State.CHASE:
follow_path()
func update_path_to_player() -> void:
if player == null:
return
var map := get_world_2d().navigation_map
path = NavigationServer2D.map_get_path(
map,
global_position,
player.global_position,
true # otimiza o caminho
)
path_index = 0
func follow_path() -> void:
if path_index >= path.size():
velocity = Vector2.ZERO
move_and_slide()
return
var target := path[path_index]
var direction := (target - global_position).normalized()
velocity = direction * speed
move_and_slide()
if global_position.distance_to(target) < 10.0:
path_index += 1
Repare que eu pego o player com get_tree().get_first_node_in_group("player") em vez de chumbar o caminho "/root/World/Player". Caminho absoluto de nó quebra na hora que você renomeia uma cena ou instancia o inimigo em outro lugar. Grupo é mais robusto: marque o player com o grupo player no editor e pronto. Se você ainda não usa grupos pra organizar cena, o guia de grupos de nodes no Godot mostra o padrão completo.
Obstáculos que se mexem
Porta que abre, plataforma que sai do lugar: nesses casos a navegação precisa saber que o mapa mudou. A forma direta é habilitar ou desabilitar a região de navegação quando o estado muda. Se a porta tampa o caminho, desligue aquela região; quando abrir, ligue de volta e o próximo recálculo de rota já considera a passagem livre.
extends AnimatableBody2D
@export var blocked_region: NavigationRegion2D
func _on_door_closed() -> void:
blocked_region.enabled = false
func _on_door_opened() -> void:
blocked_region.enabled = true
Pra muitos agentes desviando uns dos outros em tempo real, o Godot tem o NavigationAgent2D com avoidance embutido. Vale olhar quando você passar de uns poucos inimigos na tela, mas pra começar a abordagem de ligar e desligar região resolve.
Behavior Trees: quando a FSM aperta
Máquina de estados é ótima até o número de transições explodir. Dez estados, cada um podendo ir pra vários outros, e você tem um grafo que ninguém entende mais. Behavior tree resolve isso organizando o comportamento numa árvore de decisão, onde dá pra reaproveitar pedaços e adicionar comportamento novo sem mexer no que já existe.
Os tipos de nó
- Sequence: roda os filhos em ordem e para no primeiro que falhar. É um "E": ataca SE estiver em alcance E der pra mirar.
- Selector: roda os filhos em ordem e para no primeiro que tiver sucesso. É um "OU": tenta atacar, senão persegue, senão patrulha.
- Action: faz alguma coisa (atacar, fugir) e retorna sucesso, falha ou "ainda rodando".
- Condition: checa algo (player visível? vida baixa?) e retorna sucesso ou falha.
Um inimigo típico:
Selector (raiz)
├── Sequence (Combate)
│ ├── Condition: player em alcance
│ ├── Action: virar pro player
│ └── Action: atacar
├── Sequence (Perseguição)
│ ├── Condition: player avistado
│ └── Action: ir pro player
└── Action: patrulhar (fallback)
O Selector tenta combate; se o player não está em alcance, a Sequence de combate falha e ele cai pra perseguição; se nem isso, patrulha. A prioridade é a ordem dos filhos. Isso é o que torna behavior tree fácil de estender: pra dar um comportamento de fuga com vida baixa, você só insere outra Sequence no topo da lista.
Uma implementação enxuta
Dá pra escrever um interpretador de behavior tree em poucas linhas. O contrato é um só: todo nó tem um tick(agent) que devolve um Status.
# behavior_tree.gd
class_name BehaviorTree
enum Status { SUCCESS, FAILURE, RUNNING }
class Node:
func tick(_agent) -> int:
return Status.FAILURE
class Sequence extends Node:
var children: Array[Node] = []
func tick(agent) -> int:
for child in children:
var result := child.tick(agent)
if result != Status.SUCCESS:
return result # falhou ou ainda rodando: para aqui
return Status.SUCCESS
class Selector extends Node:
var children: Array[Node] = []
func tick(agent) -> int:
for child in children:
var result := child.tick(agent)
if result != Status.FAILURE:
return result # sucesso ou ainda rodando: para aqui
return Status.FAILURE
class Condition extends Node:
var check: Callable
func tick(agent) -> int:
return Status.SUCCESS if check.call(agent) else Status.FAILURE
class Action extends Node:
var run: Callable
func tick(agent) -> int:
return run.call(agent)
Montando a árvore
No inimigo, você monta a árvore uma vez no _ready e dá um tick por frame. As condições e ações são funções normais do inimigo, passadas como Callable.
extends CharacterBody2D
@export var detection_range := 300.0
@export var attack_range := 60.0
@export var chase_speed := 150.0
var player: Node2D = null
var tree: BehaviorTree.Node
func _ready() -> void:
player = get_tree().get_first_node_in_group("player")
tree = build_tree()
func _physics_process(_delta: float) -> void:
tree.tick(self)
func build_tree() -> BehaviorTree.Node:
var combat := BehaviorTree.Sequence.new()
combat.children = [
_condition(is_player_in_attack_range),
_action(face_player),
_action(attack_player),
]
var chase := BehaviorTree.Sequence.new()
chase.children = [
_condition(is_player_spotted),
_action(chase_player),
]
var root := BehaviorTree.Selector.new()
root.children = [combat, chase, _action(patrol_area)]
return root
func _condition(fn: Callable) -> BehaviorTree.Condition:
var node := BehaviorTree.Condition.new()
node.check = fn
return node
func _action(fn: Callable) -> BehaviorTree.Action:
var node := BehaviorTree.Action.new()
node.run = fn
return node
func is_player_in_attack_range(_agent) -> bool:
return player != null and global_position.distance_to(player.global_position) < attack_range
func is_player_spotted(_agent) -> bool:
return player != null and global_position.distance_to(player.global_position) < detection_range
func attack_player(_agent) -> int:
# dispara o ataque aqui
return BehaviorTree.Status.SUCCESS
func face_player(_agent) -> int:
look_at(player.global_position)
return BehaviorTree.Status.SUCCESS
func chase_player(_agent) -> int:
var direction := (player.global_position - global_position).normalized()
velocity = direction * chase_speed
move_and_slide()
return BehaviorTree.Status.RUNNING
func patrol_area(_agent) -> int:
# lógica de patrulha
return BehaviorTree.Status.RUNNING
Quando usar plugin em vez de escrever do zero
Esse interpretador de cima ensina como a coisa funciona por dentro e dá conta de inimigo simples. Pra projeto grande, com editor visual da árvore e nós prontos, vale olhar o Beehave ou o LimboAI, dois plugins de behavior tree bem conhecidos da comunidade Godot. Minha sugestão honesta: escreva o seu primeiro na mão pra entender o modelo, depois adote um plugin quando a árvore ficar grande demais pra manter no olho.
Steering Behaviors: movimento que não parece robô
Mover em linha reta até o alvo funciona, mas fica mecânico. Steering behaviors, a ideia clássica do Craig Reynolds, dão movimento mais orgânico calculando uma força de direção a cada frame em vez de teletransportar a velocidade pro valor desejado. Aqui eu cubro o essencial; se quiser o conjunto completo (arrive, pursuit, evade e flocking) com código pronto, veja o guia de steering behaviors no Godot.
A base é sempre a mesma conta: você define uma velocidade desejada e a força de steering é a diferença entre ela e a velocidade atual.
Seek (ir até o alvo)
func seek(target_pos: Vector2) -> Vector2:
var desired := (target_pos - global_position).normalized() * max_speed
return desired - velocity
Flee (fugir)
É o seek ao contrário: a velocidade desejada aponta pra longe da ameaça.
func flee(threat_pos: Vector2) -> Vector2:
var desired := (global_position - threat_pos).normalized() * max_speed
return desired - velocity
Wander (vagar)
O truque do wander é projetar um círculo um pouco à frente do agente e mirar num ponto que anda devagar pela borda desse círculo. Isso dá uma caminhada errante sem aquele zigue-zague nervoso de quem sorteia uma direção nova todo frame.
var wander_angle := 0.0
func wander(delta: float) -> Vector2:
wander_angle += randf_range(-1.0, 1.0) * delta * 2.0
var ahead := velocity.normalized() * 100.0
var offset := Vector2(cos(wander_angle), sin(wander_angle)) * 50.0
var target := global_position + ahead + offset
return seek(target)
Somando comportamentos
A graça de steering é que dá pra somar várias forças com pesos diferentes. Persegue o player, e ao mesmo tempo se afasta dos outros inimigos pra não empilhar todo mundo no mesmo pixel.
func _physics_process(delta: float) -> void:
var steering := Vector2.ZERO
if player_spotted:
steering += seek(player.global_position) * 1.0
else:
steering += wander(delta) * 0.5
for enemy in nearby_enemies:
steering += separation(enemy.global_position) * 0.8
velocity = (velocity + steering).limit_length(max_speed)
move_and_slide()
func separation(other_pos: Vector2) -> Vector2:
var diff := global_position - other_pos
var distance := diff.length()
if distance > 0.0 and distance < separation_radius:
# quanto mais perto, mais forte o empurrão
return (diff / distance) * (separation_radius - distance)
return Vector2.ZERO
Os pesos (1.0, 0.5, 0.8) são chute inicial. Esperar acertar de primeira é ilusão. Você vai rodar, ver os inimigos se comportando esquisito e ajustar no olho. É normal, faz parte.
Decision Making com Utility AI
Máquina de estados e behavior tree decidem por regras fixas. Utility AI decide por nota. Cada ação possível ganha uma pontuação calculada a partir do contexto atual, e a IA executa a de maior nota. É a abordagem quando "o que fazer agora" depende de muitos fatores ao mesmo tempo e você não quer escrever um if aninhado gigante.
Imagine um inimigo decidindo entre atacar, fugir e se curar:
Atacar: 0.8 (player perto, vida alta)
Fugir: 0.9 (vida baixa, em desvantagem numérica)
Curar: 0.3 (vida razoável)
-> escolhe FUGIR (maior nota)
Implementação
Cada ação tem uma lista de "considerações", funções que devolvem um número de 0 a 1. A nota final é o produto delas. Multiplicar (em vez de somar) é o pulo do gato: se qualquer consideração der zero, a ação inteira zera. Curar com a vida cheia tem que ser impossível, não só improvável.
class UtilityAction:
var label: String
var run: Callable
var considerations: Array[Callable] = []
func score(agent) -> float:
var total := 1.0
for consideration in considerations:
total *= consideration.call(agent)
if total == 0.0:
break # já zerou, não adianta continuar
return total
func decide() -> UtilityAction:
var actions := [make_attack(), make_flee(), make_heal()]
var best: UtilityAction = null
var best_score := -1.0
for action in actions:
var s := action.score(self)
if s > best_score:
best_score = s
best = action
return best
func make_attack() -> UtilityAction:
var action := UtilityAction.new()
action.label = "Atacar"
action.run = attack_player
action.considerations = [
# mais perto, melhor (cap em 500px)
func(_a): return clamp(1.0 - global_position.distance_to(player.global_position) / 500.0, 0.0, 1.0),
# mais vida, mais agressivo
func(_a): return float(health) / float(max_health),
]
return action
func make_flee() -> UtilityAction:
var action := UtilityAction.new()
action.label = "Fugir"
action.run = flee_from_player
action.considerations = [
# menos vida, mais vontade de fugir
func(_a): return 1.0 - float(health) / float(max_health),
# em desvantagem numérica
func(_a): return 1.0 if nearby_enemies.size() > 3 else 0.2,
]
return action
Utility AI dá um comportamento que parece pensado, porque a decisão muda de forma suave conforme o contexto, sem aquele liga-desliga seco da máquina de estados. O preço é que fica mais difícil prever exatamente por que a IA fez tal escolha. Por isso vale logar as notas de cada ação enquanto desenvolve.
Se você tem muitos agentes calculando notas e caminhos por frame e bate num gargalo de CPU, esse é um dos poucos casos em que a linguagem importa: vale conferir o que muda entre C# e GDScript no Godot antes de sair reescrevendo tudo.
::blog-cta{title="Domine Todas as Disciplinas de Game Development" description="IA é apenas uma das muitas skills. Jogos completos requerem programação, arte, design, áudio e mais. Aprenda a orquestrar todas as disciplinas ou a colaborar efetivamente com especialistas." buttonText="Candidate-se Agora" icon="fas fa-brain" variant="highlight"}::
Percepção: como a IA enxerga e escuta
Toda essa lógica de decisão depende de uma coisa: o inimigo saber o que está acontecendo. Sem percepção, ele decide no escuro. As duas mais comuns são visão e audição.
Visão em cone, com checagem de parede
Visão de verdade não é só "está dentro do raio". São três checagens: distância, ângulo (o cone de visão) e linha de visada livre. Essa última é a que importa, sem ela o inimigo enxerga você através das paredes, que é o tipo de trapaça que o jogador percebe na hora. A checagem de visada usa um raycast; se nunca mexeu com isso, o guia de raycast 2D no Godot cobre o disparo e a leitura da colisão passo a passo.
extends Node2D
@export var vision_range := 300.0
@export var vision_angle := 60.0 # graus, abertura total do cone
func can_see(target: Node2D) -> bool:
# 1. distância
var distance := global_position.distance_to(target.global_position)
if distance > vision_range:
return false
# 2. dentro do cone?
var to_target := (target.global_position - global_position).normalized()
var forward := Vector2.RIGHT.rotated(global_rotation)
var angle := rad_to_deg(forward.angle_to(to_target))
if abs(angle) > vision_angle / 2.0:
return false
# 3. tem parede no caminho?
var space := get_world_2d().direct_space_state
var query := PhysicsRayQueryParameters2D.create(
global_position, target.global_position
)
query.exclude = [self]
var hit := space.intersect_ray(query)
# vê se o raycast bateu direto no alvo (nada na frente)
return hit and hit.collider == target
O raycast precisa rodar dentro de _physics_process, porque o espaço de física só está atualizado lá. Chamar intersect_ray em outro lugar pode devolver resultado de um frame atrasado.
Audição por sinal
Audição é mais simples e até mais elegante. Em vez do inimigo ficar "escutando" todo frame, quem faz barulho emite um sinal com a posição e o volume. Cada IA que ouve compara a distância com o alcance do som e decide se vai investigar.
# em qualquer coisa que faça barulho (passo, tiro, porta)
extends Node2D
signal noise_made(position: Vector2, loudness: float)
func make_noise(loudness: float) -> void:
noise_made.emit(global_position, loudness)
# no inimigo que escuta
func _on_noise_made(noise_pos: Vector2, loudness: float) -> void:
if global_position.distance_to(noise_pos) <= loudness:
investigate(noise_pos)
Esse modelo dá stealth de graça: tiro tem loudness alto e alerta meio mapa, passo agachado tem loudness baixo e só alerta quem está colado. O design da furtividade vira ajuste de número.
Debugar IA sem perder a cabeça
Bug de IA é traiçoeiro porque raramente trava o jogo. O inimigo só faz algo idiota e você não sabe o porquê. A única saída sã é tornar o estado interno visível. Não confie em adivinhar.
Desenhar o que a IA "pensa"
O _draw do Godot deixa você pintar o alcance de detecção, o caminho atual e o estado em cima do inimigo. Vale ouro: dá pra ver na hora se o caminho está furado ou se o cone de visão está apontando pro lado errado.
func _draw() -> void:
if not OS.is_debug_build():
return
# alcance de detecção
draw_circle(Vector2.ZERO, detection_range, Color(1, 0, 0, 0.15))
# caminho atual (em coordenadas locais)
for i in range(path.size() - 1):
draw_line(
to_local(path[i]),
to_local(path[i + 1]),
Color.YELLOW, 2.0
)
# estado atual escrito em cima
draw_string(
ThemeDB.fallback_font,
Vector2(0, -40),
State.keys()[current_state],
HORIZONTAL_ALIGNMENT_CENTER, -1, 16, Color.WHITE
)
Lembre que _draw só repinta quando você chama queue_redraw(). Pra debug que muda todo frame, chame queue_redraw() no fim do _physics_process.
Logar as transições
Quando o inimigo entra num estado errado, o log de transições mostra exatamente quando e a partir de onde. Coloque um print na sua função de transição:
func transition_to(new_state: State) -> void:
print("%s: %s -> %s" % [
name,
State.keys()[current_state],
State.keys()[new_state]
])
current_state = new_state
Pra utility AI, logue as notas de cada ação na hora da decisão. Quando a IA fizer algo estranho, você vê na hora qual consideração inflou a nota errada.
Por onde começar
A maior armadilha aqui é querer fazer tudo de uma vez. Behavior tree com utility AI, percepção completa e steering no primeiro inimigo é receita pra travar. Vai por partes:
- Máquina de estados primeiro. Parado, perseguindo, atacando. Só isso já dá um inimigo que funciona e é divertido de jogar contra.
- Pathfinding depois. Quando o inimigo grudar na primeira parede, plugue o
NavigationServer. - Percepção em seguida. Cone de visão com checagem de parede, pra ele parar de te enxergar através de tudo.
- Balanceamento por último. Ajusta alcance, velocidade e os pesos no olho até ficar justo.
Uma última coisa que custei a entender: IA não tem que ser perfeita, tem que ser divertida. Às vezes dar um erro deliberado pro inimigo, um tempo de reação, uma janela pra você escapar, deixa o jogo melhor do que a versão "perfeita" que nunca erra. O inimigo perfeito é o mais chato de enfrentar.
Abre o Godot e faz o inimigo mais burro possível: persegue o player e ataca quando chega perto. Roda, vê funcionar, e vai empilhando o resto por cima. É assim que sai do papel.
Perguntas frequentes
Como fazer pathfinding no Godot 4?
Use o NavigationServer nativo. Você cria um NavigationRegion2D com um NavigationPolygon desenhando a área andável, e o Godot gera o navmesh recortando as colisões. Na IA, peça o caminho com NavigationServer2D.map_get_path a cada meio segundo (com um Timer, não todo frame) e siga os pontos do caminho. Se a geometria mudar, lembre de fazer o bake de novo.
Qual a diferença entre state machine e behavior tree para IA de jogo?
State machine (FSM) é o padrão mais simples e resolve uns 80% dos inimigos: a entidade está sempre em um estado (parado, perseguindo, atacando) e troca por condições. Behavior tree entra quando o número de transições explode: organiza o comportamento numa árvore de decisão hierárquica, mais fácil de estender e reaproveitar. Comece pela FSM e só migre quando ela apertar.
Preciso de machine learning para fazer IA de inimigo?
Não. Game AI é outra disciplina: o objetivo não é a resposta ótima, é criar a ilusão de comportamento inteligente. A maioria dos inimigos que você respeita em jogos comerciais roda em cima de uma máquina de estados de poucas linhas. Rede neural e machine learning quase nunca aparecem em IA de gameplay.
Como impedir que o inimigo enxergue o jogador através das paredes?
Faça três checagens na visão: distância, ângulo (o cone de visão) e linha de visada livre. A terceira é a que importa: dispare um raycast do inimigo até o alvo com intersect_ray dentro do _physics_process e só considere que ele viu se o raio bater direto no jogador, sem parede no meio. Sem essa checagem, o inimigo trapaceia e o jogador percebe na hora.


