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

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:
- Representação de grid: como guardar o estado de dezenas de células sem criar um nó gigante e confuso.
- Vizinhança: quase toda a lógica do jogo depende de olhar as 8 células ao redor de uma posição.
- 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.
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,ALTURAeTOTAL_MINASem 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
_vizinhose_revelar. - Recorde: salve o melhor tempo com
FileAccesse mostre na tela inicial. - Visual: cores por número (1 azul, 2 verde, 3 vermelho) usando
add_theme_color_overrideno 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.


