Voltar para o Blog
Quest Log

Como Fazer um Campo Minado na Godot 4: Tutorial 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.

Ferramenta:Godot

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. Se este é seu primeiro contato com o motor, o guia de como fazer um jogo 2D na Godot cobre o básico de cena, nó e script antes de você começar por aqui.

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.

Curso de Godot
Quer fazer seu primeiro jogo na Godot?

No curso de Godot da CursoGame.Dev você vai do zero ao primeiro jogo, com um desafio prático em cada level e suporte individual no WhatsApp quando travar.

Conhecer o curso de Godot
Suporte no WhatsAppAcesso vitalícioGarantia de 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.