Voltar para o Blog
Quest Log

Padrões de Projeto (Design Patterns) em Jogos: State, Observer e Component na Prática

Ilustração de engrenagens e blocos conectados representando padrões de projeto em jogos

Guia prático de design patterns em jogos: State, Observer e Component em GDScript no Godot 4, com critérios honestos de quando aplicar cada um.

Padrões de Projeto (Design Patterns) em Jogos: State, Observer e Component na Prática

Todo projeto de jogo passa pelo mesmo ciclo: o protótipo nasce com scripts simples, o jogo cresce, e seis meses depois o arquivo do player tem 800 linhas, quinze booleanos tipo is_jumping, is_attacking, is_stunned, e ninguém mais sabe o que acontece quando dois deles ficam verdadeiros ao mesmo tempo. Design patterns em jogos existem pra resolver exatamente esse tipo de bagunça, e três deles resolvem a maior parte: State, Observer e Component.

Esse artigo mostra os três com código GDScript real do Godot 4, e o mais importante: o critério de quando usar cada um. Porque pattern aplicado cedo demais é tão ruim quanto pattern nenhum. Eu já vi projeto de uma fase só com sistema de eventos genérico de três camadas, e já vi jogo comercial onde o boss era um if de 200 linhas. Os dois extremos doem.

O que são design patterns em jogos (e o que não são)

Design patterns são soluções com nome pra problemas que se repetem. Em vez de você reinventar do zero "como organizar os comportamentos do personagem", existe uma resposta testada chamada State. Em vez de inventar "como a UI fica sabendo que o player tomou dano", existe Observer.

O que eles não são: regra obrigatória. O livro Game Programming Patterns, do Robert Nystrom (que você pode ler de graça no site do autor), martela isso o tempo todo: pattern é ferramenta, não meta. Você não ganha ponto por usar mais patterns. Ganha por entregar um jogo que dá pra modificar sem medo.

O sinal de que você precisa de um pattern é sempre o mesmo: dor. Código que você tem medo de mexer, bug que volta toda vez que você adiciona uma feature, copy-paste do mesmo trecho pela quinta vez. Se não dói, não refatore ainda.

State: o fim da selva de booleanos

O problema que o State resolve aparece em todo jogo de ação. O personagem pode estar parado, correndo, pulando, caindo, atacando. Cada comportamento tem regras próprias: atacando não pode pular, caindo não pode atacar, e por aí vai. A solução ingênua é uma flag booleana pra cada coisa, e aí começam as combinações impossíveis: is_jumping e is_on_ground verdadeiros juntos, e o personagem faz o moonwalk no ar.

A ideia do State é simples: o personagem está em exatamente um estado por vez, cada estado define seu próprio comportamento, e as trocas de estado são explícitas. Em vez de quinze flags, uma variável.

A versão mais direta no Godot usa um enum e um match:

extends CharacterBody2D

enum State { IDLE, RUN, JUMP, FALL }

const SPEED = 300.0
const JUMP_VELOCITY = -400.0

var state := State.IDLE
var gravity = ProjectSettings.get_setting("physics/2d/default_gravity")

@onready var sprite: AnimatedSprite2D = $AnimatedSprite2D

func _physics_process(delta):
    if not is_on_floor():
        velocity.y += gravity * delta

    var direction := Input.get_axis("ui_left", "ui_right")
    velocity.x = direction * SPEED

    if Input.is_action_just_pressed("ui_accept") and is_on_floor():
        velocity.y = JUMP_VELOCITY

    move_and_slide()
    _update_state(direction)

func _update_state(direction: float) -> void:
    var new_state := state

    if is_on_floor():
        new_state = State.RUN if direction != 0 else State.IDLE
    else:
        new_state = State.JUMP if velocity.y < 0 else State.FALL

    if new_state != state:
        state = new_state
        _enter_state(new_state)

func _enter_state(new_state: State) -> void:
    # Tudo que acontece UMA vez na troca de estado fica aqui.
    match new_state:
        State.IDLE:
            sprite.play("idle")
        State.RUN:
            sprite.play("run")
        State.JUMP:
            sprite.play("jump")
        State.FALL:
            sprite.play("fall")

Repare na separação: a função _enter_state roda uma única vez quando o estado muda. É ali que você toca animação, som de pulo, partícula de poeira. Sem essa separação, o play("run") rodaria todo frame e reiniciaria a animação sem parar, que é um bug clássico de quem está começando.

Quando o enum não basta

O enum com match aguenta uns quatro ou cinco estados. Quando o personagem ganha dash, wall slide, ataque no ar e knockback, o arquivo volta a inchar e cada estado novo mexe nas mesmas funções gigantes. Aí vale subir pra versão com nodes: cada estado vira um script próprio com métodos enter(), exit() e physics_update(delta), todos filhos de um node StateMachine que delega pro estado ativo. O comportamento do dash fica inteiro em dash_state.gd, e adicionar um estado novo é criar um arquivo, não editar dez match.

Minha regra prática: comece com enum. Migre pra state machine de nodes quando passar de cinco estados ou quando dois programadores precisarem mexer em estados diferentes ao mesmo tempo. Migrar é mecânico, cada braço do match vira um script.

O State também não é só pra player. Inimigo com patrulha, perseguição e ataque é uma state machine. Fluxo de telas (menu, jogando, pausado, game over) é uma state machine. Aprendeu uma vez, usa em todo lugar.

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

Observer: a UI não precisa conhecer o player

Segundo problema universal: o player toma dano, e a barra de vida precisa atualizar, o som de dano precisa tocar, a tela precisa tremer, e o achievement de "sobreviva com 1 de HP" precisa checar. A solução ingênua é o player chamar todo mundo:

# O jeito que vira bola de neve. Não faça isso.
func take_damage(amount: int) -> void:
    health -= amount
    get_node("/root/Main/HUD/HealthBar").value = health
    get_node("/root/Main/CameraShake").shake()
    get_node("/root/Main/AchievementTracker").check_low_hp(health)

Agora o player conhece a HUD, a câmera e o sistema de achievements. Se qualquer um deles mudar de lugar na árvore, o player quebra. Se você testar o player numa cena isolada, ele crasha porque os caminhos não existem. Esse acoplamento é o que mata projeto grande.

O Observer inverte a direção: o player só anuncia o que aconteceu com ele, e quem tiver interesse escuta. No Godot isso é nativo, são os signals. Você usa Observer há tempos sem saber o nome.

# player.gd
extends CharacterBody2D

signal health_changed(current: int, total: int)
signal died

var max_health := 100
var health := 100

func take_damage(amount: int) -> void:
    health = max(health - amount, 0)
    health_changed.emit(health, max_health)
    if health == 0:
        died.emit()

O player não sabe quem escuta, e não precisa saber. Cada interessado se conecta por conta própria:

# hud.gd
extends CanvasLayer

@onready var health_bar: ProgressBar = $HealthBar

func _ready():
    var player := get_node("%Player")
    player.health_changed.connect(_on_player_health_changed)

func _on_player_health_changed(current: int, total: int) -> void:
    health_bar.max_value = total
    health_bar.value = current

A regra de ouro pra decidir a direção: chame pra baixo, sinalize pra cima. Um node pode chamar métodos dos próprios filhos diretamente, isso é normal. Mas pra falar com pai, irmão ou qualquer coisa distante na árvore, emita um signal e deixe o outro lado conectar. O player chama $AnimatedSprite2D.play() sem culpa; pra avisar a HUD, ele emite.

E quando os dois lados nem estão na mesma cena? Game over precisa ser ouvido pelo gerenciador de fases, pelo sistema de save e pela música, espalhados pela árvore inteira. Pra esses eventos globais, o padrão no Godot é um event bus: um autoload pequeno que só declara signals, e todo mundo emite e conecta através dele. Funciona muito bem, com um aviso: cada signal que vai pro bus global é um signal que ninguém mais rastreia facilmente. Use pra eventos de jogo de verdade (morte, fase completa, item raro), não pra toda comunicação. Se tudo passa pelo bus, você trocou acoplamento explícito por acoplamento invisível, que é pior de debugar.

Component: composição em vez de herança

Terceiro problema: você tem player, inimigo, baú e parede destrutível. Todos têm vida. O instinto de quem veio de orientação a objetos clássica é criar uma hierarquia: Entity > DamageableEntity > MovableDamageableEntity... e em pouco tempo aparece o caso que não encaixa, tipo a torreta que toma dano e atira mas não se move, e a hierarquia desmorona.

O Component resolve trocando "ser" por "ter". Em vez de o inimigo ser uma entidade que herda vida, ele tem um componente de vida. O Godot empurra você nessa direção naturalmente, porque a árvore de cenas é composição pura: cada habilidade vira um node reutilizável que você pluga em quem precisar.

# health_component.gd
class_name HealthComponent
extends Node

signal health_changed(current: int, total: int)
signal died

@export var max_health := 100

var health: int

func _ready():
    health = max_health

func take_damage(amount: int) -> void:
    health = max(health - amount, 0)
    health_changed.emit(health, max_health)
    if health == 0:
        died.emit()

Agora qualquer cena que precise de vida adiciona um node HealthComponent como filho e configura o max_health no Inspector. O dono decide o que fazer quando morre:

# barril_explosivo.gd
extends StaticBody2D

@onready var health: HealthComponent = $HealthComponent

func _ready():
    health.died.connect(_on_died)

func _on_died() -> void:
    # Aqui entra a explosão, partícula, som...
    queue_free()

O player conecta o mesmo died numa tela de game over. O baú, numa animação de abrir. O componente é idêntico nos três; só a reação muda. E repare que Component e Observer trabalham juntos: o componente avisa por signal, o dono decide a resposta. É assim que esses patterns aparecem na prática, combinados, não isolados.

O mesmo molde serve pra HitboxComponent, HurtboxComponent, KnockbackComponent, PickupComponent. Times que adotam isso montam inimigo novo em minutos, juntando peças prontas e escrevendo só o comportamento único dele.

O cuidado aqui é a granularidade. Componente de vida, ótimo. Componente pra cada três linhas de código, e sua cena vira uma árvore de 30 nodes onde achar qualquer lógica exige expedição. Extraia um componente quando a segunda entidade precisar do mesmo comportamento, não antes. Duplicar uma vez é mais barato que abstrair errado.

Qual pattern usar pra cada dor

Resumo de decisão, do jeito que eu uso:

  • Muitos booleanos de comportamento se atropelando (is_jumping, is_attacking...): State. Enum até uns cinco estados, nodes a partir daí.
  • Um objeto precisando avisar vários outros, ou referências tipo get_node("/root/...") cruzando o projeto: Observer, via signals. Eventos verdadeiramente globais vão pra um event bus autoload, com moderação.
  • O mesmo comportamento copiado em entidades diferentes, ou hierarquia de herança que não fecha: Component, com nodes reutilizáveis.

E uma menção honesta a dois que você vai esquentar a cabeça em breve: Singleton, que no Godot é o autoload, serve pra estado realmente global (configurações, progresso de save), mas vira depósito de tudo se você deixar; e Object Pool, que recicla objetos em vez de instanciar e destruir sem parar, só vale quando o profiler mostrar que spawn de projétil está custando caro de verdade.

Conclusão

State, Observer e Component cobrem a grande maioria das dores de arquitetura de um jogo indie, e no Godot os três são quase nativos: match e nodes pro State, signals pro Observer, a própria árvore de cenas pro Component. Você não precisa decorar o catálogo inteiro do Gang of Four pra escrever jogo bem estruturado.

O caminho que funciona é o inverso do que parece: não comece pelo pattern, comece pelo jogo. Escreva o código simples, sinta a dor quando ela aparecer, e aí aplique o pattern que resolve aquela dor específica. Pattern aprendido assim você nunca mais esquece, porque ele tem o formato exato do problema que te fez sofrer.