Voltar para o Blog
Quest Log

Como Fazer um Campo Minado na Godot 4: Tutorial Completo em GDScript

Tutorial de como fazer um campo minado na Godot 4 com GDScript tipado

Campo minado na Godot 4: tutorial completo com grid de células, sorteio de minas após o primeiro clique, flood fill iterativo, bandeiras e cronômetro.

Fazer um campo minado na Godot é um dos melhores exercícios para quem já passou da fase de copiar tutoriais e quer pensar como programador de jogos. O projeto cabe em uma única cena, não precisa de arte, e mesmo assim cobre grid 2D, vizinhança de células, flood fill, estado de jogo e interface. Neste tutorial você vai montar o jogo completo na Godot 4 com GDScript tipado: tabuleiro de botões, sorteio de minas depois do primeiro clique, revelação em cascata com pilha, bandeiras no clique direito, contador e cronômetro.

Por que o campo minado é um clássico perfeito para aprender

O campo minado parece simples, mas esconde três problemas fundamentais de programação de jogos:

  1. Representação de grid: como guardar o estado de dezenas de células sem criar um nó gigante e confuso.
  2. Vizinhança: quase toda a lógica do jogo depende de olhar as 8 células ao redor de uma posição.
  3. Propagação: revelar uma célula vazia dispara a revelação das vizinhas, que dispara a revelação das vizinhas delas. Isso é flood fill, o mesmo algoritmo do balde de tinta de editores de imagem e de mecânicas de território em jogos de estratégia.

Esses três padrões aparecem em roguelikes, jogos de fazenda, match-3 e praticamente qualquer jogo baseado em tabuleiro. Se você quer entender o que torna um puzzle bom além do código, o guia de design de puzzle complementa bem este tutorial.

A estrutura de dados: célula e grid

A regra de ouro: os dados do jogo não moram nos botões. Os botões são só a casca visual. O estado de cada célula fica em uma classe leve:

class_name Celula
extends RefCounted

var tem_mina: bool = false
var minas_vizinhas: int = 0
var revelada: bool = false
var tem_bandeira: bool = false

RefCounted é a base certa aqui: a célula é pura informação, não precisa entrar na árvore de cena. Para o grid, você tem duas opções válidas na Godot 4: um Array de Array (matriz 2D) ou um Dictionary com chaves Vector2i. Vou usar o dicionário porque ele torna a checagem de borda trivial: se a chave não existe, a posição está fora do tabuleiro. Nada de testar x >= 0 and x < largura em quatro lugares diferentes.

Crie uma cena com um Control como raiz, um GridContainer filho chamado GridContainer e dois Label dentro de um HBoxContainer chamado HUD (um para bandeiras, outro para o tempo). O script principal começa assim:

extends Control

const LARGURA: int = 10
const ALTURA: int = 10
const TOTAL_MINAS: int = 15

var grid: Dictionary = {}
var botoes: Dictionary = {}
var minas_sorteadas: bool = false
var jogo_ativo: bool = true
var bandeiras_usadas: int = 0
var tempo_decorrido: float = 0.0

@onready var container: GridContainer = $GridContainer
@onready var rotulo_bandeiras: Label = $HUD/RotuloBandeiras
@onready var rotulo_tempo: Label = $HUD/RotuloTempo

func _ready() -> void:
    container.columns = LARGURA
    _criar_grid()

Montando o grid do campo minado na Godot

Cada posição ganha uma Celula (dados) e um Button (visual). O botão é conectado via gui_input, e não pelo sinal pressed, porque pressed só responde ao clique esquerdo e nós vamos precisar do clique direito para a bandeira:

func _criar_grid() -> void:
    for y: int in ALTURA:
        for x: int in LARGURA:
            var pos: Vector2i = Vector2i(x, y)
            grid[pos] = Celula.new()
            var botao: Button = Button.new()
            botao.custom_minimum_size = Vector2(32, 32)
            botao.gui_input.connect(_ao_clicar.bind(pos))
            container.add_child(botao)
            botoes[pos] = botao

func _ao_clicar(evento: InputEvent, pos: Vector2i) -> void:
    if not jogo_ativo:
        return
    var clique: InputEventMouseButton = evento as InputEventMouseButton
    if clique == null or not clique.pressed:
        return
    if clique.button_index == MOUSE_BUTTON_LEFT:
        _revelar(pos)
    elif clique.button_index == MOUSE_BUTTON_RIGHT:
        _alternar_bandeira(pos)

O bind(pos) anexa a posição ao sinal: cada botão sabe qual coordenada representa sem precisar de script próprio. Se preferir um visual de mapa com sprites em vez de botões, a mesma lógica de dados funciona com a abordagem do guia de TileMap na Godot 4, trocando apenas a camada de apresentação.

Sorteando as minas depois do primeiro clique

Aqui está o detalhe que separa um campo minado amador de um bem feito: as minas não são sorteadas no _ready(). Elas são sorteadas no primeiro clique, excluindo a célula clicada e as 8 vizinhas. Resultado: o primeiro clique nunca perde e quase sempre abre uma área útil.

Antes do sorteio, defina os 8 deslocamentos de vizinhança e duas funções auxiliares:

const OFFSETS: Array[Vector2i] = [
    Vector2i(-1, -1), Vector2i(0, -1), Vector2i(1, -1),
    Vector2i(-1, 0),                   Vector2i(1, 0),
    Vector2i(-1, 1),  Vector2i(0, 1),  Vector2i(1, 1),
]

func _vizinhos(pos: Vector2i) -> Array[Vector2i]:
    var resultado: Array[Vector2i] = []
    for offset: Vector2i in OFFSETS:
        var vizinho: Vector2i = pos + offset
        if grid.has(vizinho):
            resultado.append(vizinho)
    return resultado

func _contar_vizinhas(pos: Vector2i) -> int:
    var total: int = 0
    for vizinho: Vector2i in _vizinhos(pos):
        var celula: Celula = grid[vizinho]
        if celula.tem_mina:
            total += 1
    return total

Repare no ganho do dicionário: grid.has(vizinho) resolve a borda do tabuleiro em uma linha. Célula do canto tem 3 vizinhas, célula da borda tem 5, célula do meio tem 8, e você não escreveu um único if de limite.

O sorteio embaralha as posições candidatas e marca as primeiras TOTAL_MINAS:

func _sortear_minas(clique_inicial: Vector2i) -> void:
    var zona_segura: Array[Vector2i] = _vizinhos(clique_inicial)
    zona_segura.append(clique_inicial)
    var candidatas: Array[Vector2i] = []
    for pos: Vector2i in grid.keys():
        if pos not in zona_segura:
            candidatas.append(pos)
    candidatas.shuffle()
    for i: int in TOTAL_MINAS:
        var celula: Celula = grid[candidatas[i]]
        celula.tem_mina = true
    for pos: Vector2i in grid.keys():
        var celula: Celula = grid[pos]
        celula.minas_vizinhas = _contar_vizinhas(pos)
    minas_sorteadas = true

Depois de posicionar as minas, o script já calcula e guarda o número de vizinhas de cada célula. Calcular uma vez e armazenar é mais barato do que recontar a cada clique.

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

Revelação em cascata: flood fill iterativo

Quando o jogador clica em uma célula com minas_vizinhas == 0, o jogo revela automaticamente todas as vizinhas, e se alguma delas também for zero, o processo continua se espalhando. A tentação é resolver isso com recursão: _revelar chama _revelar nas vizinhas. Funciona em um grid 10x10. Mas em um tabuleiro 50x50 com uma área vazia grande, cada célula empilha uma nova chamada de função antes de a anterior terminar, e milhares de chamadas aninhadas podem estourar o limite de pilha do interpretador e derrubar o jogo.

A solução profissional é o flood fill iterativo: uma pilha explícita (um Array comum) e um laço while. Mesmo resultado, memória sob controle, e de quebra fica fácil depurar:

func _revelar(pos_inicial: Vector2i) -> void:
    var celula_inicial: Celula = grid[pos_inicial]
    if celula_inicial.revelada or celula_inicial.tem_bandeira:
        return
    if not minas_sorteadas:
        _sortear_minas(pos_inicial)
    if celula_inicial.tem_mina:
        _derrota()
        return
    var pilha: Array[Vector2i] = [pos_inicial]
    while not pilha.is_empty():
        var pos: Vector2i = pilha.pop_back()
        var celula: Celula = grid[pos]
        if celula.revelada or celula.tem_bandeira:
            continue
        celula.revelada = true
        _atualizar_botao(pos)
        if celula.minas_vizinhas == 0:
            for vizinho: Vector2i in _vizinhos(pos):
                var vizinha: Celula = grid[vizinho]
                if not vizinha.revelada and not vizinha.tem_mina:
                    pilha.append(vizinho)
    _checar_vitoria()
    
func _atualizar_botao(pos: Vector2i) -> void:
    var celula: Celula = grid[pos]
    var botao: Button = botoes[pos]
    botao.disabled = true
    if celula.minas_vizinhas > 0:
        botao.text = str(celula.minas_vizinhas)
    else:
        botao.text = ""

O fluxo do laço: tira uma posição da pilha, revela, e só empurra as vizinhas para a pilha se a célula atual for zero. Células com número são reveladas mas não propagam, exatamente como no jogo original. A checagem if celula.revelada dentro do laço evita processar a mesma célula duas vezes quando ela entra na pilha por caminhos diferentes.

Clique direito: bandeira e contador

A bandeira é o jeito do jogador anotar "aqui tem mina". Célula com bandeira não pode ser revelada (o _revelar acima já ignora), e o contador mostra quantas bandeiras foram usadas contra o total de minas:

func _alternar_bandeira(pos: Vector2i) -> void:
    var celula: Celula = grid[pos]
    if celula.revelada:
        return
    celula.tem_bandeira = not celula.tem_bandeira
    bandeiras_usadas += 1 if celula.tem_bandeira else -1
    var botao: Button = botoes[pos]
    botao.text = "F" if celula.tem_bandeira else ""
    rotulo_bandeiras.text = "Bandeiras: %d / %d" % [bandeiras_usadas, TOTAL_MINAS]

Troque o "F" por um ícone via botao.icon quando quiser polir o visual. O importante é a regra: bandeira é um estado da célula, não do botão.

Vitória, derrota e cronômetro

A condição de vitória do campo minado costuma confundir: você não precisa marcar todas as minas. Vence quando todas as células sem mina estão reveladas. A checagem percorre o grid e desiste no primeiro contraexemplo:

func _checar_vitoria() -> void:
    for pos: Vector2i in grid.keys():
        var celula: Celula = grid[pos]
        if not celula.tem_mina and not celula.revelada:
            return
    jogo_ativo = false
    rotulo_tempo.text = "Vitória em %d segundos!" % int(tempo_decorrido)

func _derrota() -> void:
    jogo_ativo = false
    for pos: Vector2i in grid.keys():
        var celula: Celula = grid[pos]
        if celula.tem_mina:
            var botao: Button = botoes[pos]
            botao.text = "*"

Na derrota, o jogo revela todas as minas: é o feedback clássico que deixa o jogador ver onde errou. Por fim, o cronômetro. Nada de nó Timer acumulando segundos: um acumulador de delta no _process é mais simples e só roda depois do primeiro clique, quando minas_sorteadas vira verdadeiro:

func _process(delta: float) -> void:
    if jogo_ativo and minas_sorteadas:
        tempo_decorrido += delta
        rotulo_tempo.text = "Tempo: %d s" % int(tempo_decorrido)

Com isso o jogo está completo: rode a cena, clique em qualquer célula e o tabuleiro abre com segurança garantida no primeiro clique.

Próximos passos

Você acabou de implementar, em um único projeto, os padrões que sustentam boa parte dos jogos de grid: dados separados da apresentação, vizinhança por offsets, flood fill iterativo e máquina de estados simples (jogando, vitória, derrota). Algumas direções naturais para evoluir:

  • Dificuldades: transforme LARGURA, ALTURA e TOTAL_MINAS em variáveis e crie os modos fácil, médio e difícil do clássico.
  • Chording: clicar em um número já revelado com o total certo de bandeiras ao redor revela as demais vizinhas de uma vez. É só reutilizar _vizinhos e _revelar.
  • Recorde: salve o melhor tempo com FileAccess e mostre na tela inicial.
  • Visual: cores por número (1 azul, 2 verde, 3 vermelho) usando add_theme_color_override no botão.

Se a estrutura de tabuleiro te interessou, o tutorial de como fazer um jogo de tabuleiro digital leva esses mesmos conceitos para outro gênero, com turnos e regras de movimento. Outro clássico de grid que treina o mesmo raciocínio de células e estados é o Sokoban na Godot, com movimento discreto, empurrar de caixas e desfazer de jogadas. O campo minado é pequeno o bastante para terminar em um fim de semana e denso o bastante para você sair dele programando melhor do que entrou. Abra a Godot e comece pelo grid.

Perguntas frequentes

Por que sortear as minas só depois do primeiro clique?

Porque isso garante que o primeiro clique nunca perde. Você registra a posição clicada, remove ela e as vizinhas do sorteio e só então distribui as minas. É o comportamento do campo minado clássico do Windows e elimina a frustração de perder por puro azar na primeira jogada.

Devo usar recursão ou pilha para revelar as células em cascata?

Use uma pilha com laço while (flood fill iterativo). A versão recursiva funciona em grids pequenos, mas em tabuleiros grandes uma área vazia extensa pode encadear milhares de chamadas e estourar o limite de pilha do interpretador. A versão iterativa faz o mesmo trabalho com custo de memória controlado.

É melhor usar Buttons em um GridContainer ou um TileMap para o tabuleiro?

Para um jogo de interface como o campo minado, GridContainer com Buttons é mais simples: você ganha clique, hover e desabilitar de graça. TileMap compensa quando você quer visual de mapa com sprites e camadas. A lógica de dados fica idêntica nos dois casos, só muda a camada de apresentação.

Como detectar o clique direito em um Button na Godot 4?

Conecte o sinal gui_input do Button e verifique se o evento é um InputEventMouseButton com pressed verdadeiro e button_index igual a MOUSE_BUTTON_RIGHT. O sinal pressed padrão do Button só cobre o clique esquerdo, por isso o gui_input é necessário para a bandeira.

Como funciona a condição de vitória no campo minado?

Você vence quando todas as células que não têm mina estão reveladas. Não é preciso marcar bandeira em todas as minas. Basta percorrer o grid depois de cada revelação: se sobrar qualquer célula segura ainda fechada, o jogo continua; se não sobrar nenhuma, é vitória.