Máquina de estados na Godot 4: organize o comportamento com FSM em GDScript

Aprenda a montar uma máquina de estados godot limpa em GDScript para inimigos e player, com estados idle, patrulha, perseguir, atacar e morrer.
Máquina de estados na Godot 4: organize o comportamento com FSM em GDScript
Se você já abriu o _physics_process de um inimigo e encontrou um if de trinta linhas com condições aninhadas para "está vendo o player", "está no alcance de ataque", "a vida acabou" e "voltou pra patrulha", este tutorial é pra você. A máquina de estados godot é o padrão que transforma essa bagunça em blocos pequenos, isolados e fáceis de ler. Aqui o foco não é a IA em si, e sim a ARQUITETURA que segura qualquer comportamento: um inimigo, o player, um chefe ou até um portão que abre e fecha.
Uma máquina de estados finita (FSM, de finite state machine) parte de uma ideia simples: um agente está sempre em exatamente UM estado por vez. Um inimigo está parado, OU patrulhando, OU perseguindo, OU atacando, OU morrendo. Nunca dois ao mesmo tempo. Cada estado sabe o que fazer enquanto está ativo e sabe quando deve ceder o lugar para outro. Só isso já organiza noventa por cento do trabalho.
Por que o "if gigante" apodrece
O problema do bloco condicional único não é ele existir, é ele crescer. Cada comportamento novo obriga você a reler todas as condições anteriores para garantir que a nova não colide com nenhuma. Some a isso variáveis de controle espalhadas (is_attacking, can_move, was_chasing) e você tem um estado implícito que ninguém consegue rastrear.
Repare no antipadrão:
func _physics_process(delta: float) -> void:
if health <= 0:
# lógica de morte
pass
elif player_visible and distance < attack_range:
# lógica de ataque
pass
elif player_visible:
# lógica de perseguição
pass
elif has_patrol_points:
# lógica de patrulha
pass
else:
# lógica de idle
pass
Funciona com cinco estados. Agora imagine adicionar "fugir com vida baixa", "recarregar", "atordoado" e "chamar reforço". A ordem dos elif passa a importar de um jeito frágil, e um estado consegue vazar variáveis para o outro sem você perceber. A FSM resolve isso dando a cada comportamento sua própria caixa fechada.
A anatomia da FSM: State base e StateMachine
A implementação limpa tem duas peças. A primeira é uma classe State base que define o contrato: todo estado tem um método de entrada, um de saída e um que roda a cada frame. A segunda é a StateMachine, que guarda o estado atual e cuida das transições.
Vamos usar a abordagem por nós, que é a mais idiomática na Godot 4. Cada estado é um nó filho da máquina, então você vê a árvore inteira no editor e pode configurar exports por estado.
Comece pela classe base:
class_name State
extends Node
# Emitido quando o estado pede uma transição.
# A máquina escuta e faz a troca.
signal transitioned(new_state_name: String)
# Referência ao dono do comportamento (inimigo ou player).
var agent: CharacterBody2D = null
# Chamado uma vez, ao entrar no estado.
func enter() -> void:
pass
# Chamado uma vez, ao sair do estado.
func exit() -> void:
pass
# Lógica de frame com física. Recebe o delta.
func physics_update(delta: float) -> void:
pass
# Entrada bruta, útil para pulos e ataques do player.
func handle_input(_event: InputEvent) -> void:
pass
O signal transitioned é o coração do desacoplamento. Um estado nunca troca a si mesmo diretamente; ele só ANUNCIA que quer mudar, e a máquina decide. Isso evita que os estados fiquem se conhecendo uns aos outros.
Agora a máquina:
class_name StateMachine
extends Node
@export var initial_state: State
@export var agent: CharacterBody2D
var current_state: State = null
var states: Dictionary = {}
func _ready() -> void:
# Registra todos os estados filhos pelo nome.
for child in get_children():
if child is State:
var state := child as State
states[state.name.to_lower()] = state
state.agent = agent
state.transitioned.connect(_on_state_transitioned)
if initial_state != null:
current_state = initial_state
current_state.enter()
func _physics_process(delta: float) -> void:
if current_state != null:
current_state.physics_update(delta)
func _unhandled_input(event: InputEvent) -> void:
if current_state != null:
current_state.handle_input(event)
func _on_state_transitioned(new_state_name: String) -> void:
var key := new_state_name.to_lower()
if not states.has(key):
push_warning("Estado inexistente: " + new_state_name)
return
var new_state := states[key] as State
if new_state == current_state:
return
current_state.exit()
current_state = new_state
current_state.enter()
Repare no fluxo: a máquina só chama physics_update do estado ativo. Nada de if/elif. Quando um estado emite transitioned, a máquina roda o exit do velho e o enter do novo. Simples e à prova de vazamento.
Estados concretos para um inimigo
Com a base pronta, os estados viram arquivos pequenos e legíveis. Cada um herda de State e implementa só o que importa. Veja o Idle, que espera um tempo e volta a patrulhar:
class_name EnemyIdle
extends State
@export var idle_time: float = 1.5
var _timer: float = 0.0
func enter() -> void:
_timer = idle_time
agent.velocity = Vector2.ZERO
func physics_update(delta: float) -> void:
_timer -= delta
if _timer <= 0.0:
transitioned.emit("EnemyPatrol")
A patrulha anda entre pontos e passa a perseguir se avistar o player:
class_name EnemyPatrol
extends State
@export var speed: float = 60.0
@export var detection_range: float = 180.0
var _target_point: Vector2 = Vector2.ZERO
func enter() -> void:
_target_point = _pick_next_point()
func physics_update(_delta: float) -> void:
var to_target := _target_point - agent.global_position
if to_target.length() < 8.0:
_target_point = _pick_next_point()
agent.velocity = to_target.normalized() * speed
agent.move_and_slide()
if _player_distance() <= detection_range:
transitioned.emit("EnemyChase")
func _player_distance() -> float:
var player := agent.get_tree().get_first_node_in_group("player") as Node2D
if player == null:
return INF
return agent.global_position.distance_to(player.global_position)
func _pick_next_point() -> Vector2:
# Substitua pela sua lógica de waypoints.
return agent.global_position + Vector2(randf_range(-120, 120), 0)
E o Chase, que persegue e cede para o ataque quando chega perto:
class_name EnemyChase
extends State
@export var speed: float = 110.0
@export var attack_range: float = 32.0
@export var give_up_range: float = 260.0
func physics_update(_delta: float) -> void:
var player := agent.get_tree().get_first_node_in_group("player") as Node2D
if player == null:
transitioned.emit("EnemyIdle")
return
var dist := agent.global_position.distance_to(player.global_position)
if dist <= attack_range:
transitioned.emit("EnemyAttack")
return
if dist >= give_up_range:
transitioned.emit("EnemyPatrol")
return
var dir := (player.global_position - agent.global_position).normalized()
agent.velocity = dir * speed
agent.move_and_slide()
Cada estado é um capítulo curto. Quando o ataque precisar de um tempo de recarga, você mexe SÓ no EnemyAttack. Nenhum outro estado sente o impacto. É esse isolamento que a FSM te dá de graça, e é por isso que ela aguenta chefes com dez ou quinze comportamentos sem virar espaguete.
Um estado de ataque e o estado de morte
O ataque tem um detalhe importante: ele não fica preso. Ele dispara o golpe, espera o cooldown e reavalia. Aqui é onde a FSM conversa bem com o seu sistema de dano por hitbox e hurtbox com Area2D, já que o enter é o gancho natural para ligar a hitbox e o exit para desligá-la.
class_name EnemyAttack
extends State
@export var cooldown: float = 0.8
var _timer: float = 0.0
func enter() -> void:
agent.velocity = Vector2.ZERO
_timer = cooldown
# Aqui você dispararia a animação e ligaria a hitbox.
func physics_update(delta: float) -> void:
_timer -= delta
if _timer <= 0.0:
transitioned.emit("EnemyChase")
O estado de morte é terminal: ele entra e não sai. Serve para tocar a animação, desligar colisões e liberar o nó.
class_name EnemyDead
extends State
func enter() -> void:
agent.velocity = Vector2.ZERO
agent.set_physics_process(false)
# Toca animação de morte e depois remove o agente.
await agent.get_tree().create_timer(0.6).timeout
agent.queue_free()
Para transições que podem acontecer de QUALQUER estado, como levar dano fatal, exponha um método na máquina em vez de repetir a checagem em todo estado:
# Dentro da StateMachine
func force_transition(state_name: String) -> void:
_on_state_transitioned(state_name)
Assim, o CharacterBody2D chama state_machine.force_transition("EnemyDead") quando a vida zera, sem que nenhum estado precise saber da regra global.
A alternativa por enum e dicionário
Nem todo projeto quer nós para cada estado. Para agentes muito simples, dá para guardar tudo num único script com um enum e um dicionário de referências. Fica mais compacto, ao custo de perder a edição visual no editor:
class_name SimpleFSM
extends CharacterBody2D
enum States { IDLE, PATROL, CHASE, ATTACK, DEAD }
var current: States = States.IDLE
func _physics_process(delta: float) -> void:
match current:
States.IDLE:
_state_idle(delta)
States.PATROL:
_state_patrol(delta)
States.CHASE:
_state_chase(delta)
States.ATTACK:
_state_attack(delta)
States.DEAD:
pass
func _change_state(new_state: States) -> void:
current = new_state
func _state_idle(_delta: float) -> void:
velocity = Vector2.ZERO
# transição de exemplo
# _change_state(States.PATROL)
O match sobre um enum é bem melhor que o if encadeado porque cada ramo é isolado e o compilador ajuda a não esquecer um caso. Mas repare: ainda é tudo no mesmo arquivo. Quando os comportamentos ganham peso, a versão com State base e nós envelhece muito melhor. Uma regra prática: até três estados, o enum basta; daí pra cima, migre para classes.
Reaproveitando no player
O maior ganho da arquitetura de nós é que ela não sabe se o agente é inimigo ou player. O motor de troca é idêntico. Para o player você só escreve estados diferentes: PlayerIdle, PlayerRun, PlayerJump, PlayerAttack. O handle_input da classe base, que deixamos vazio no inimigo, agora faz sentido, porque é ele que lê o botão de pulo e de ataque.
Isso também explica por que vale investir na versão tipada e organizada desde cedo. A mesma StateMachine que roda o goblin roda o herói, roda o chefe e roda a plataforma móvel. Se você está montando um jogo com muitos inimigos, essa base combina direto com um sistema de spawn em ondas: cada inimigo instanciado já chega com sua própria máquina e seus estados prontos. E se quiser aprofundar a parte de percepção e movimento, a lógica de IA de inimigo para perseguir e patrulhar encaixa dentro dos estados que montamos aqui, sem tocar na arquitetura.
Vale um comentário sobre linguagem: toda essa estrutura funciona igualmente bem em C#, e a escolha entre as duas é mais sobre preferência e equipe do que sobre a FSM em si. Se essa dúvida te pega, o comparativo entre C# e GDScript na Godot ajuda a decidir. Quer você use uma ou outra, o padrão de estados é o mesmo.
Boas práticas para a FSM não virar dívida técnica
Alguns hábitos mantêm a máquina saudável conforme o projeto cresce:
- Um estado nunca chama
enterouexitde outro. Ele só emitetransitioned. A máquina é a única dona das transições. - Coloque no
entertudo que precisa ser resetado (timers, velocidade, animação). Assim reentrar num estado sempre parte do zero. - Não guarde regras globais dentro dos estados. Morte, atordoamento e knockback merecem um
force_transitionna máquina. - Nomeie estados por comportamento, não por condição.
Chaseé melhor queWhenPlayerVisible, porque o nome descreve o que o agente FAZ. - Para depurar, imprima ou mostre num Label o
current_state.name. Como só um estado roda por vez, isso já te diz exatamente onde a lógica está.
Se quiser se aprofundar de verdade na Godot 4, com projetos guiados que passam por arquitetura, IA e polimento, vale conhecer o melhor curso de Godot em português, que trata esses padrões dentro de jogos completos e não em exemplos soltos.
Conclusão e próximo passo
A máquina de estados finita não é um truque avançado, é a forma padrão de organizar comportamento em jogos. Você troca um _physics_process cheio de if por um conjunto de classes pequenas, cada uma responsável por uma coisa só, e ganha um motor de transições que serve para inimigos, player e qualquer objeto com comportamento.
O próximo passo é prático: pegue o inimigo mais bagunçado do seu projeto e refatore o comportamento dele para essa estrutura de State e StateMachine. Comece com Idle e Patrol, valide a troca, e vá adicionando Chase, Attack e Dead um de cada vez. Quando sentir o quanto fica fácil adicionar um estado novo sem medo de quebrar os outros, você não volta mais para o if gigante.
Perguntas frequentes
O que é uma máquina de estados finita na Godot?
É um padrão que representa o comportamento como um conjunto de estados exclusivos (idle, patrulha, perseguir, atacar, morrer) e regras de transição entre eles. Só um estado roda por vez, o que deixa o código previsível e fácil de depurar.
Preciso de FSM ou o if no _physics_process resolve?
Para dois ou três comportamentos o if resolve. A partir de quatro ou cinco estados o if vira uma bola de neve de condições aninhadas. A FSM isola cada comportamento na sua própria classe e evita esse crescimento descontrolado.
Dá para usar a mesma FSM no player e no inimigo?
Sim. A arquitetura de State base e StateMachine é agnóstica. Você troca só os estados concretos: o player usa Idle, Correr, Pular e Atacar, o inimigo usa Patrulha, Perseguir e Atacar. O motor de troca de estados é o mesmo.
Qual a diferença entre abordagem por nós e por dicionário de enum?
A abordagem por nós usa cada estado como um nó filho, ótima para editar no editor e reaproveitar. A abordagem por dicionário e enum guarda instâncias em memória, é mais enxuta e boa para agentes simples. Ambas resolvem o mesmo problema.
Como debugar qual estado está ativo?
Guarde o nome do estado atual e imprima na transição, ou mostre num Label acima do personagem. Como só um estado roda por vez, saber o estado ativo já te diz exatamente qual bloco de lógica está executando.


