Voltar para o Blog
Quest Log

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

Diagrama de uma máquina de estados finita na Godot 4 com estados idle, patrulha, perseguir e atacar

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.

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

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 enter ou exit de outro. Ele só emite transitioned. A máquina é a única dona das transições.
  • Coloque no enter tudo 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_transition na máquina.
  • Nomeie estados por comportamento, não por condição. Chase é melhor que WhenPlayerVisible, 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.