Voltar para o Blog
Quest Log

Como Fazer um Jogo de Cartas no Godot 4: Baralho, Mão e Efeitos

Mesa de jogo de cartas digital com cartas em leque na mão, pilha de compra e pilha de descarte, sem texto.

Jogo de cartas no Godot 4: estruture a carta como Resource, monte baralho, mão e descarte, embaralhe, jogue cartas e resolva efeitos com GDScript tipado.

Como Fazer um Jogo de Cartas no Godot 4: Baralho, Mão e Efeitos

Um jogo de cartas no estilo Slay the Spire ou Balatro parece complexo por causa das centenas de combinações possíveis, mas o esqueleto é sempre o mesmo: um baralho que você compra, uma mão que você joga, uma pilha de descarte que volta a embaralhar quando a compra acaba. Se você acerta esse fluxo no Godot 4, o resto é conteúdo. Neste tutorial você vai montar a mecânica base de um deckbuilder com GDScript tipado e comentado: representar a carta como dado, controlar baralho, mão e descarte, jogar uma carta resolvendo o efeito dela e conectar tudo à interface de forma conceitual.

O foco aqui é arquitetura de dados e fluxo, não arte. A regra que vale a leitura inteira: o estado real do jogo não vive nas cartas desenhadas na tela, vive em alguns Arrays de dados. As cartas visuais são só um espelho desses Arrays.

Por que a carta é um Resource no Godot

A primeira decisão de um jogo de cartas no Godot é como guardar o que uma carta é. Nome, custo de energia, tipo e valor do efeito são dados puros. Eles não processam frame a frame, não têm posição no mundo, não precisam estar na árvore de cenas. Isso é a definição exata de um Resource.

Usar um Resource customizado traz três ganhos diretos. Você edita cada carta pelo Inspector, como se fosse um formulário. Você salva cada carta em um arquivo .tres, então um game designer cria cartas novas duplicando arquivos sem encostar em código. E você ganha tipagem estática: o campo custo é int, o campo tipo é um enum, e o compilador reclama se você errar. Se quiser se aprofundar nessa ideia de separar dado de comportamento, o guia de custom Resources no Godot detalha as pegadinhas de compartilhamento por referência.

Vamos definir a classe da carta. Crie um script carta.gd:

# carta.gd
# A carta e DADO, nao comportamento. Por isso estende Resource e nao Node.
class_name Carta
extends Resource

# Enum tipado para o tipo de carta. Usar enum em vez de string
# evita erros de digitacao e deixa o match la na frente limpo.
enum Tipo {
    ATAQUE,    # causa dano no alvo
    DEFESA,    # gera bloqueio para o jogador
    COMPRA,    # faz o jogador comprar cartas
}

@export var nome: String = "Carta"
@export var custo: int = 1            # energia necessaria para jogar
@export var tipo: Tipo = Tipo.ATAQUE
@export var valor: int = 6            # dano, bloqueio ou nro de cartas
@export_multiline var descricao: String = ""
@export var arte: Texture2D          # imagem da carta, usada so na UI

Com esse script salvo, o Godot reconhece Carta como um tipo. No painel FileSystem você clica com o botão direito, escolhe criar um novo Resource, seleciona Carta e preenche os campos. Cada carta vira um .tres. Uma "Investida" pode ser tipo ATAQUE com valor 6. Um "Defender" pode ser tipo DEFESA com valor 5. Nenhuma linha de código nova para isso.

Baralho, mão e descarte: as três pilhas

O coração do deckbuilder são três coleções de cartas. A pilha de compra (de onde você puxa), a mão (o que está jogável agora) e a pilha de descarte (para onde a carta vai depois de usada). Como cada uma é uma lista de objetos Carta, o tipo natural é Array[Carta], um Array tipado.

Crie um node, por exemplo um Node chamado GerenciadorDeBaralho, com o script abaixo. Comece pela estrutura e pelo embaralhamento.

# gerenciador_baralho.gd
extends Node

# As tres pilhas, todas Arrays tipados de Carta.
var pilha_compra: Array[Carta] = []
var mao: Array[Carta] = []
var pilha_descarte: Array[Carta] = []

# Gerador proprio de aleatoriedade. Usar um RNG dedicado
# permite fixar uma seed e reproduzir partidas para depurar.
var rng := RandomNumberGenerator.new()

# O baralho inicial, montado a partir dos arquivos .tres das cartas.
@export var baralho_inicial: Array[Carta] = []

func _ready() -> void:
    rng.randomize()  # seed aleatoria; troque por rng.seed = 123 para fixar
    iniciar_baralho()

# Copia o baralho inicial para a pilha de compra e embaralha.
func iniciar_baralho() -> void:
    pilha_compra = baralho_inicial.duplicate()
    mao.clear()
    pilha_descarte.clear()
    embaralhar(pilha_compra)

# Fisher-Yates manual usando o RNG proprio.
# Percorre de tras para frente trocando cada carta por outra aleatoria.
func embaralhar(pilha: Array[Carta]) -> void:
    for i in range(pilha.size() - 1, 0, -1):
        var j: int = rng.randi_range(0, i)
        var temp: Carta = pilha[i]
        pilha[i] = pilha[j]
        pilha[j] = temp

Repare em dois detalhes. O baralho_inicial.duplicate() evita mexer no Array original exportado, então reiniciar a partida sempre parte do mesmo conjunto. E o embaralhamento usa o RandomNumberGenerator próprio em vez do shuffle() global, o que dá controle de seed. Se você nunca configurou aleatoriedade reproduzível, vale ler sobre aleatoriedade e RNG no Godot antes de seguir, porque seed previsível salva horas de depuração.

Agora a parte central do fluxo: comprar e descartar. Comprar tira a última carta da pilha de compra e coloca na mão. O ponto crítico é o que fazer quando a pilha de compra acaba.

# Compra uma carta. Se a pilha de compra estiver vazia,
# reembaralha o descarte de volta antes de tentar de novo.
func comprar_carta() -> Carta:
    if pilha_compra.is_empty():
        reembaralhar_descarte()
    # Se mesmo apos reembaralhar nao ha cartas, nao ha o que comprar.
    if pilha_compra.is_empty():
        return null
    var carta: Carta = pilha_compra.pop_back()
    mao.append(carta)
    return carta

# Compra varias cartas de uma vez, util no inicio do turno.
func comprar_varias(quantidade: int) -> void:
    for _i in range(quantidade):
        comprar_carta()

# Move a carta da mao para a pilha de descarte.
func descartar(carta: Carta) -> void:
    var indice: int = mao.find(carta)
    if indice != -1:
        mao.remove_at(indice)
        pilha_descarte.append(carta)

# Descarta a mao inteira, tipico do fim de turno em deckbuilders.
func descartar_mao() -> void:
    for carta in mao:
        pilha_descarte.append(carta)
    mao.clear()

# O ciclo que mantem o jogo girando: quando a compra acaba,
# o descarte vira a nova pilha de compra, embaralhado.
func reembaralhar_descarte() -> void:
    pilha_compra = pilha_descarte.duplicate()
    pilha_descarte.clear()
    embaralhar(pilha_compra)

Esse reembaralhar_descarte é a peça que muita gente esquece e que define o gênero. Sem ele, o jogador esvazia o baralho e trava. Com ele, o mesmo punhado de cartas circula a partida inteira, e é por isso que adicionar uma carta forte ao baralho importa tanto: ela vai voltar.

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

Jogar uma carta e resolver o efeito

Jogar uma carta tem duas etapas: validar (o jogador tem energia para isso?) e resolver (o que a carta faz?). A energia é um recurso simples por turno. A resolução do efeito é onde o enum Tipo e o match brilham.

Vamos guardar um pouco de estado de combate no próprio gerenciador para o exemplo ficar completo: energia do jogador, bloqueio acumulado e uma referência ao alvo (o inimigo). Em um projeto real esse estado moraria em scripts próprios de jogador e inimigo, mas aqui ele deixa o fluxo claro.

# Estado de combate (simplificado para o exemplo).
var energia: int = 3
var bloqueio_jogador: int = 0
var vida_inimigo: int = 40

# Sinal emitido sempre que uma carta e jogada com sucesso.
# A UI escuta isso para atualizar a tela.
signal carta_jogada(carta: Carta)

# Tenta jogar uma carta da mao. Retorna true se conseguiu.
func jogar_carta(carta: Carta) -> bool:
    # Validacao 1: a carta esta mesmo na mao?
    if not mao.has(carta):
        return false
    # Validacao 2: ha energia suficiente?
    if carta.custo > energia:
        return false

    energia -= carta.custo  # paga o custo
    resolver_efeito(carta)   # aplica o que a carta faz
    descartar(carta)         # carta usada vai para o descarte
    carta_jogada.emit(carta) # avisa a UI
    return true

# O coracao do sistema de efeitos: um match sobre o enum Tipo.
# Cada ramo aplica uma acao usando carta.valor.
func resolver_efeito(carta: Carta) -> void:
    match carta.tipo:
        Carta.Tipo.ATAQUE:
            # Dano direto no inimigo.
            vida_inimigo -= carta.valor
            vida_inimigo = max(vida_inimigo, 0)
        Carta.Tipo.DEFESA:
            # Acumula bloqueio para o proximo ataque inimigo.
            bloqueio_jogador += carta.valor
        Carta.Tipo.COMPRA:
            # Compra um numero de cartas igual ao valor.
            comprar_varias(carta.valor)

A beleza desse desenho é que adicionar um tipo novo de carta é adicionar um valor ao enum Tipo e um ramo ao match. Quer uma carta que cura? Adicione CURA ao enum, um campo de vida do jogador e um ramo no match. Nada do resto muda. Para jogos maiores, você evolui de "um tipo por carta" para "uma lista de efeitos por carta", mas o enum com match segura a base do gênero por bastante tempo.

Vale notar que resolver_efeito não sabe nada sobre a tela. Ele só altera números. Isso é proposital: você pode testar toda a lógica de combate sem nunca abrir uma cena.

O loop de turno em poucas linhas

Com baralho, mão e efeitos prontos, o turno do jogador se resume a coordenar essas peças. No começo do turno você recarrega a energia e compra a mão. No fim, descarta o que sobrou.

const ENERGIA_POR_TURNO: int = 3
const CARTAS_POR_TURNO: int = 5

# Prepara o turno do jogador.
func iniciar_turno() -> void:
    energia = ENERGIA_POR_TURNO
    bloqueio_jogador = 0          # bloqueio costuma zerar a cada turno
    comprar_varias(CARTAS_POR_TURNO)

# Encerra o turno do jogador e passa a vez.
func encerrar_turno() -> void:
    descartar_mao()
    # Aqui entraria o turno do inimigo: ele escolheria uma acao,
    # atacaria descontando do bloqueio_jogador antes da vida, etc.
    # A IA do inimigo e um capitulo a parte e nao muda este fluxo.

Esse é o relógio do jogo: iniciar_turno, jogador joga cartas chamando jogar_carta, encerrar_turno, inimigo age, repete. A IA do inimigo pode ser tão simples quanto escolher um ataque de uma lista, e isso não interfere na arquitetura de cartas que você acabou de montar.

Conectando à interface com signals

Até aqui tudo é dado. A interface entra para deixar a mão visível e clicável, sem nunca virar a fonte da verdade. O padrão é direto: para cada carta na mão, você instancia uma cópia de uma cena de carta e entrega a ela o Resource correspondente.

Crie uma cena CartaUI (um Control ou Button com TextureRect e Label filhos) com este script:

# carta_ui.gd
extends Control

# Sinal que avisa quem estiver ouvindo que esta carta foi clicada.
signal clicada(carta: Carta)

var dados: Carta  # o Resource que esta carta representa

# Recebe o dado e desenha nome, custo e arte a partir dele.
func configurar(carta: Carta) -> void:
    dados = carta
    $Nome.text = carta.nome
    $Custo.text = str(carta.custo)
    $Arte.texture = carta.arte

# Conectado ao sinal pressed() do botao no editor ou via codigo.
func _ao_clicar() -> void:
    clicada.emit(dados)  # propaga o dado, nao o node

Do lado do gerenciador (ou de um node de UI da mão), você redesenha a mão sempre que ela muda: apaga as cartas visuais antigas e cria uma para cada carta do Array mao.

# Em um node responsavel pela mao na tela.
@export var cena_carta: PackedScene  # a cena CartaUI

# Redesenha a mao inteira a partir do estado atual.
func atualizar_mao_visual(mao_atual: Array[Carta]) -> void:
    # Remove as cartas visuais antigas.
    for filho in get_children():
        filho.queue_free()
    # Cria uma instancia para cada carta do dado.
    for carta in mao_atual:
        var ui: Node = cena_carta.instantiate()
        add_child(ui)
        ui.configurar(carta)
        # Liga o clique da carta a uma funcao deste node.
        ui.clicada.connect(_ao_clicar_carta)

# Quando o jogador clica numa carta, pede ao gerenciador para joga-la.
func _ao_clicar_carta(carta: Carta) -> void:
    gerenciador.jogar_carta(carta)
    atualizar_mao_visual(gerenciador.mao)  # redesenha apos a jogada

O fluxo fecha o ciclo. O clique vira um signal, o signal chama jogar_carta, a lógica altera os Arrays e a UI se redesenha a partir do novo estado. Se a comunicação por signal ainda não está clara para você, o guia de signals no Godot com GDScript mostra o padrão de conectar e emitir em detalhe, e é o que segura essa separação entre lógica e tela.

Para onde levar a partir daqui

Você tem o esqueleto completo de um deckbuilder: carta como Resource, três pilhas em Array[Carta], embaralhamento com RNG próprio, compra com reembaralhamento automático, resolução de efeito por enum e match, e uma UI que só espelha o dado. Esse é o coração do gênero, e ele aguenta crescimento sem reescrita.

A partir daqui, os próximos passos costumam ser: estados de combate (veneno, fraqueza) como um dicionário de efeitos persistentes, custo de energia variável, cartas que afetam outras cartas e a tão falada IA de inimigo. Se você quer entender melhor onde esse tipo de jogo se encaixa no mercado e quais decisões de design o definem, vale ler sobre gêneros de jogos explicados. E se a intenção é dominar o Godot a fundo, sem ficar pulando de tutorial em tutorial, o melhor curso de Godot cobre do zero até projetos completos com a mesma mentalidade de separar dado de comportamento que guiou este tutorial inteiro.

Perguntas frequentes

Por que usar Resource para representar uma carta no Godot?

Porque a carta e dado, nao comportamento. Um Resource customizado guarda nome, custo, tipo e valor em um arquivo .tres editavel pelo Inspector, sem precisar de cena nem de node. Voce cria dezenas de cartas duplicando arquivos e ajustando campos, sem tocar em codigo, e ainda ganha type safety e serializacao nativa. A parte visual da carta fica numa cena separada que apenas le esse dado.

Como embaralhar um baralho de cartas em GDScript?

A forma mais direta e usar o metodo shuffle() de um Array, que embaralha no lugar usando o gerador global do Godot. Se voce quer resultado reproduzivel (por exemplo para depurar ou para uma seed fixa), crie um RandomNumberGenerator, defina a seed e implemente um Fisher-Yates manual trocando posicoes com randi_range. Assim voce controla a aleatoriedade em vez de depender do estado global.

O que fazer quando a pilha de compra acaba no meio da partida?

Voce reembaralha o descarte de volta para a pilha de compra. Quando o jogador tenta comprar e a pilha de compra esta vazia, voce move todas as cartas da pilha de descarte para a de compra, embaralha de novo e continua comprando. Esse ciclo compra, mao, jogar, descarte, reembaralhar e o coracao de qualquer deckbuilder.

Como organizar os efeitos das cartas sem um if gigante?

Use um enum tipado para o tipo da carta (dano, bloqueio, comprar) e um match no momento de resolver o efeito. Cada ramo do match aplica uma acao especifica usando o campo valor da carta. Para jogos maiores, voce evolui isso para uma lista de efeitos por carta ou para o padrao de comando, mas o enum com match ja resolve muito bem a base do genero.

Preciso de uma cena para cada carta na tela?

Voce cria uma unica cena de carta reutilizavel e instancia uma copia dela para cada carta que esta na mao. Cada instancia recebe o Resource correspondente e desenha nome, custo e arte a partir desse dado. Quando a mao muda, voce destroi as instancias antigas e recria a partir do estado atual da mao. O clique na carta e propagado por um signal ate a logica do jogo.

Da para fazer um jogo de cartas no Godot sendo iniciante?

Da, desde que voce ja tenha os fundamentos de cena, node e signal firmes. A logica de baralho, mao e descarte e quase toda manipulacao de Array, que e acessivel. A parte que exige mais cuidado e separar o dado da carta da sua representacao visual e organizar o fluxo de turno. Comece com cartas de dano e bloqueio e so depois adicione tipos mais complexos.