Campo Minado parece simples à primeira vista — é um grid com números e minas. Mas implementá-lo do zero revela várias decisões técnicas interessantes: como distribuir minas de forma verdadeiramente aleatória? Como garantir que o primeiro clique nunca seja uma mina? Como implementar o reveal em cascata de células vazias sem travar a UI? Neste artigo mostro como resolvi cada uma dessas questões no Campo Minado Evolution.
A estrutura de dados: modelando uma célula
Cada posição do tabuleiro é representada por um objeto Celula com todo o estado
necessário para renderização e lógica do jogo:
class Celula {
final int linha;
final int coluna;
bool temMina;
bool revelada;
bool marcada; // bandeirinha do jogador
int minasAdjacentes;
Celula({required this.linha, required this.coluna})
: temMina = false,
revelada = false,
marcada = false,
minasAdjacentes = 0;
}
O tabuleiro inteiro é uma lista bidimensional inicializada com células limpas. Nenhuma mina é colocada na criação — isso é intencional, como veremos em seguida.
List<List<Celula>> tabuleiro = List.generate(
linhas,
(l) => List.generate(colunas, (c) => Celula(linha: l, coluna: c)),
);
Distribuindo as minas: o problema do primeiro clique
Uma regra clássica do Campo Minado: o primeiro clique nunca pode explodir uma mina. Isso significa que as minas precisam ser distribuídas após o primeiro clique, excluindo a célula clicada e todos os seus vizinhos do sorteio.
void distribuirMinas(int linhaSegura, int colunaSegura) {
final rand = Random();
int minasColocadas = 0;
while (minasColocadas < totalMinas) {
int l = rand.nextInt(linhas);
int c = rand.nextInt(colunas);
// Exclui a célula clicada e os 8 vizinhos ao redor dela
bool ehZonaSegura =
(l - linhaSegura).abs() <= 1 && (c - colunaSegura).abs() <= 1;
if (!ehZonaSegura && !tabuleiro[l][c].temMina) {
tabuleiro[l][c].temMina = true;
minasColocadas++;
}
}
}
Esse loop continua sorteando posições até preencher todas as minas. A zona de exclusão de 3×3 ao redor do primeiro clique garante que o jogador sempre começa com algum espaço para raciocinar — sem explosão imediata na primeira jogada.
Calculando os números adjacentes
Após distribuir as minas, percorremos todo o tabuleiro e calculamos quantas minas existem nos 8 vizinhos de cada célula que não é mina:
void calcularAdjacentes() {
for (int l = 0; l < linhas; l++) {
for (int c = 0; c < colunas; c++) {
if (!tabuleiro[l][c].temMina) {
tabuleiro[l][c].minasAdjacentes = _contarVizinhos(l, c);
}
}
}
}
int _contarVizinhos(int l, int c) {
int count = 0;
for (int dl = -1; dl <= 1; dl++) {
for (int dc = -1; dc <= 1; dc++) {
if (dl == 0 && dc == 0) continue; // ignora a própria célula
int nl = l + dl;
int nc = c + dc;
if (nl >= 0 && nl < linhas && nc >= 0 && nc < colunas) {
if (tabuleiro[nl][nc].temMina) count++;
}
}
}
return count;
}
O reveal em cascata: flood fill recursivo
Quando o jogador clica em uma célula com minasAdjacentes == 0, todas as células
vazias conectadas devem ser reveladas automaticamente — e os números nas bordas dessa região
também. Isso é um algoritmo de flood fill (preenchimento por inundação),
o mesmo usado em ferramentas de pintura tipo balde.
void revelar(int l, int c) {
// Limites do tabuleiro
if (l < 0 || l >= linhas || c < 0 || c >= colunas) return;
final celula = tabuleiro[l][c];
// Não revela células já reveladas ou marcadas com bandeira
if (celula.revelada || celula.marcada) return;
celula.revelada = true;
// Se a célula não tem minas ao redor, revela os 8 vizinhos recursivamente
if (!celula.temMina && celula.minasAdjacentes == 0) {
for (int dl = -1; dl <= 1; dl++) {
for (int dc = -1; dc <= 1; dc++) {
if (dl == 0 && dc == 0) continue;
revelar(l + dl, c + dc);
}
}
}
}
A recursão para naturalmente quando encontra células já reveladas, marcadas com bandeira,
ou células com número maior que zero (bordas da região vazia). Em tabuleiros grandes, a
pilha de chamadas pode crescer. Para tabuleiros muito grandes, vale converter para uma
abordagem iterativa com uma fila (Queue).
Renderizando o tabuleiro com Flutter
O tabuleiro é renderizado com GridView.builder com um delegate de contagem
fixa de colunas. Cada célula é um GestureDetector que responde ao toque
simples (revelar) e ao toque longo (colocar bandeira):
GridView.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: colunas,
childAspectRatio: 1.0,
),
itemCount: linhas * colunas,
itemBuilder: (context, index) {
final l = index ~/ colunas;
final c = index % colunas;
final celula = tabuleiro[l][c];
return GestureDetector(
onTap: () => controller.revelar(l, c),
onLongPress: () => controller.alternarBandeira(l, c),
child: CelulaWidget(celula: celula),
);
},
)
O CelulaWidget recebe a célula e decide o que exibir: célula coberta, bandeira,
número colorido ou explosão. As cores dos números seguem a convenção clássica do Campo Minado:
1 azul, 2 verde, 3 vermelho, etc.
Condições de vitória e derrota
As duas condições são verificadas após cada jogada:
// Derrota: o jogador revelou uma mina
bool verificarDerrota(int l, int c) => tabuleiro[l][c].temMina;
// Vitória: todas as células SEM mina foram reveladas
bool verificarVitoria() {
return tabuleiro
.expand((row) => row)
.where((c) => !c.temMina)
.every((c) => c.revelada);
}
Na vitória, todas as minas restantes são automaticamente marcadas com bandeira e o cronômetro para. Na derrota, todas as minas são reveladas com animação e a sessão termina.
Implementar Campo Minado em Flutter é um exercício excelente porque combina algoritmos clássicos (flood fill, geração aleatória com restrição) com UI reativa. O resultado é um jogo que roda suavemente a 60fps porque toda a lógica vive fora da árvore de widgets — o estado do tabuleiro é gerenciado no controller e a UI só renderiza o que mudou.
Experimente o Campo Minado Evolution gratuitamente no Google Play:
Baixar Campo Minado Evolution