Jogo da Vida de Conway em Rust

Construa o Jogo da Vida de Conway em Rust com visualização no terminal: grid, regras de simulação, padrões clássicos e modo contínuo.

Neste projeto vamos construir o Jogo da Vida de Conway – um dos autômatos celulares mais famosos da computação – com visualização em tempo real no terminal. Inventado pelo matemático John Horton Conway em 1970, este jogo zero-jogadores demonstra como regras extremamente simples podem gerar comportamentos complexos e fascinantes. Cada célula em um grid bidimensional nasce, sobrevive ou morre baseando-se apenas no número de vizinhos vivos.

O projeto é uma ótima forma de praticar arrays bidimensionais, iteração sobre grids, manipulação de terminal e controle de tempo em Rust. Ao final, você terá um simulador funcional capaz de rodar padrões clássicos como o glider, o blinker e o pulsar.

O Que Vamos Construir

Um simulador do Jogo da Vida com as seguintes funcionalidades:

  • Grid bidimensional com bordas que se conectam (torus)
  • Implementação das 4 regras clássicas de Conway
  • Visualização em tempo real no terminal com cores
  • Padrões pré-definidos: glider, blinker, pulsar, glider gun
  • Modo passo a passo (tecla Enter) ou contínuo
  • Contador de gerações e população
  • Velocidade ajustável durante a execução
  • Inicialização aleatória com densidade configurável

Estrutura do Projeto

simulador-vida/
├── Cargo.toml
└── src/
    └── main.rs

Configurando o Projeto

Crie o projeto com o Cargo:

cargo new simulador-vida
cd simulador-vida

Edite o Cargo.toml:

[package]
name = "simulador-vida"
version = "0.1.0"
edition = "2021"

[dependencies]
crossterm = "0.28"
rand = "0.8"

Usamos crossterm para manipulação do terminal (modo raw, posicionamento do cursor, leitura de teclas) e rand para a inicialização aleatória do grid.

Passo 1: Definindo o Grid e as Regras

Vamos começar com a estrutura de dados do grid e a implementação das regras de Conway.

use crossterm::{
    cursor, event::{self, Event, KeyCode, KeyEvent},
    execute, queue,
    style::{self, Stylize},
    terminal::{self, ClearType},
};
use rand::Rng;
use std::io::{self, Write};
use std::time::{Duration, Instant};

/// Representa o grid do Jogo da Vida
struct Grid {
    celulas: Vec<Vec<bool>>,
    largura: usize,
    altura: usize,
}

impl Grid {
    /// Cria um grid vazio com as dimensões especificadas
    fn novo(largura: usize, altura: usize) -> Self {
        Grid {
            celulas: vec![vec![false; largura]; altura],
            largura,
            altura,
        }
    }

    /// Preenche o grid aleatoriamente com a densidade dada (0.0 a 1.0)
    fn aleatorio(&mut self, densidade: f64) {
        let mut rng = rand::thread_rng();
        for y in 0..self.altura {
            for x in 0..self.largura {
                self.celulas[y][x] = rng.gen_bool(densidade);
            }
        }
    }

    /// Define o estado de uma célula (com verificação de limites)
    fn definir(&mut self, x: usize, y: usize, vivo: bool) {
        if x < self.largura && y < self.altura {
            self.celulas[y][x] = vivo;
        }
    }

    /// Verifica se uma célula está viva (bordas conectadas como torus)
    fn esta_viva(&self, x: isize, y: isize) -> bool {
        // Wrapping: bordas se conectam
        let x_wrap = ((x % self.largura as isize) + self.largura as isize) as usize % self.largura;
        let y_wrap = ((y % self.altura as isize) + self.altura as isize) as usize % self.altura;
        self.celulas[y_wrap][x_wrap]
    }

    /// Conta os vizinhos vivos de uma célula (8 direções)
    fn contar_vizinhos(&self, x: usize, y: usize) -> u8 {
        let mut contagem = 0u8;
        let xi = x as isize;
        let yi = y as isize;

        for dy in -1..=1isize {
            for dx in -1..=1isize {
                if dx == 0 && dy == 0 {
                    continue; // Ignora a própria célula
                }
                if self.esta_viva(xi + dx, yi + dy) {
                    contagem += 1;
                }
            }
        }
        contagem
    }

    /// Aplica as regras de Conway e retorna o próximo estado do grid
    /// Regras:
    /// 1. Célula viva com < 2 vizinhos morre (solidão)
    /// 2. Célula viva com 2 ou 3 vizinhos sobrevive
    /// 3. Célula viva com > 3 vizinhos morre (superpopulação)
    /// 4. Célula morta com exatamente 3 vizinhos nasce (reprodução)
    fn proxima_geracao(&self) -> Grid {
        let mut novo_grid = Grid::novo(self.largura, self.altura);

        for y in 0..self.altura {
            for x in 0..self.largura {
                let vizinhos = self.contar_vizinhos(x, y);
                let viva = self.celulas[y][x];

                novo_grid.celulas[y][x] = match (viva, vizinhos) {
                    (true, 2) | (true, 3) => true,  // Sobrevive
                    (false, 3) => true,              // Nasce
                    _ => false,                       // Morre ou permanece morta
                };
            }
        }

        novo_grid
    }

    /// Conta o número total de células vivas
    fn populacao(&self) -> usize {
        self.celulas.iter()
            .flat_map(|linha| linha.iter())
            .filter(|&&c| c)
            .count()
    }
}

As regras de Conway são elegantemente expressas com pattern matching. A função proxima_geracao cria um novo grid (double buffering) para evitar que as mudanças de uma célula afetem o cálculo dos vizinhos de outra na mesma geração. O wrapping das bordas cria um torus – o que sai por um lado reaparece no oposto.

Passo 2: Padrões Clássicos

Vamos implementar funções que inserem padrões conhecidos no grid.

/// Insere um padrão no grid a partir de uma posição (x, y)
fn inserir_padrao(grid: &mut Grid, padrao: &[(usize, usize)], offset_x: usize, offset_y: usize) {
    for &(px, py) in padrao {
        grid.definir(offset_x + px, offset_y + py, true);
    }
}

/// Padrão Glider: se move diagonalmente pelo grid
fn glider() -> Vec<(usize, usize)> {
    vec![(1, 0), (2, 1), (0, 2), (1, 2), (2, 2)]
}

/// Padrão Blinker: oscila entre horizontal e vertical (período 2)
fn blinker() -> Vec<(usize, usize)> {
    vec![(0, 1), (1, 1), (2, 1)]
}

/// Padrão Pulsar: oscilador de período 3
fn pulsar() -> Vec<(usize, usize)> {
    vec![
        // Quadrante superior esquerdo (replicado nos outros 3)
        (2, 0), (3, 0), (4, 0), (8, 0), (9, 0), (10, 0),
        (0, 2), (5, 2), (7, 2), (12, 2),
        (0, 3), (5, 3), (7, 3), (12, 3),
        (0, 4), (5, 4), (7, 4), (12, 4),
        (2, 5), (3, 5), (4, 5), (8, 5), (9, 5), (10, 5),
        (2, 7), (3, 7), (4, 7), (8, 7), (9, 7), (10, 7),
        (0, 8), (5, 8), (7, 8), (12, 8),
        (0, 9), (5, 9), (7, 9), (12, 9),
        (0, 10), (5, 10), (7, 10), (12, 10),
        (2, 12), (3, 12), (4, 12), (8, 12), (9, 12), (10, 12),
    ]
}

/// Lightweight Spaceship (LWSS): se move horizontalmente
fn lwss() -> Vec<(usize, usize)> {
    vec![
        (1, 0), (4, 0),
        (0, 1),
        (0, 2), (4, 2),
        (0, 3), (1, 3), (2, 3), (3, 3),
    ]
}

/// Retorna o nome e o padrão pelo índice de seleção
fn obter_padrao(indice: u8) -> (&'static str, Vec<(usize, usize)>) {
    match indice {
        1 => ("Glider", glider()),
        2 => ("Blinker", blinker()),
        3 => ("Pulsar", pulsar()),
        4 => ("LWSS (Nave Leve)", lwss()),
        _ => ("Aleatório", vec![]),
    }
}

Cada padrão é representado como um vetor de coordenadas relativas, facilitando a inserção em qualquer posição do grid. O glider é o padrão mais icônico – uma forma que se move diagonalmente pelo grid indefinidamente.

Passo 3: Renderização e Interface

Agora implementamos a renderização do grid no terminal e a interface do usuário.

/// Renderiza o grid no terminal usando caracteres Unicode
fn renderizar(
    stdout: &mut io::Stdout,
    grid: &Grid,
    geracao: u64,
    velocidade_ms: u64,
    pausado: bool,
) {
    queue!(stdout, cursor::MoveTo(0, 0)).unwrap();

    // Barra de status
    let status = if pausado { "PAUSADO" } else { "RODANDO" };
    let info = format!(
        " Geração: {} | População: {} | Velocidade: {}ms | {} | [Espaço]=Pausar [+/-]=Velocidade [Q]=Sair ",
        geracao,
        grid.populacao(),
        velocidade_ms,
        status,
    );
    queue!(stdout, style::PrintStyledContent(info.black().on_white())).unwrap();
    queue!(stdout, cursor::MoveToNextLine(1)).unwrap();

    // Renderiza o grid
    for y in 0..grid.altura {
        let mut linha = String::with_capacity(grid.largura * 2);
        for x in 0..grid.largura {
            if grid.celulas[y][x] {
                linha.push_str("██");
            } else {
                linha.push_str("  ");
            }
        }
        queue!(stdout, style::Print(&linha), cursor::MoveToNextLine(1)).unwrap();
    }

    stdout.flush().unwrap();
}

/// Exibe o menu de seleção de padrão
fn menu_selecao(stdout: &mut io::Stdout) -> u8 {
    execute!(stdout, terminal::Clear(ClearType::All), cursor::MoveTo(0, 0)).unwrap();

    println!("{}", " JOGO DA VIDA DE CONWAY ".to_string().black().on_green());
    println!();
    println!("Selecione o padrão inicial:");
    println!();
    println!("  1. Glider (se move na diagonal)");
    println!("  2. Blinker (oscilador simples)");
    println!("  3. Pulsar (oscilador período 3)");
    println!("  4. LWSS - Nave Leve (se move na horizontal)");
    println!("  5. Aleatório (densidade 30%)");
    println!();
    print!("Escolha (1-5): ");
    stdout.flush().unwrap();

    loop {
        if event::poll(Duration::from_millis(100)).unwrap() {
            if let Event::Key(KeyEvent { code, .. }) = event::read().unwrap() {
                match code {
                    KeyCode::Char(c) if c >= '1' && c <= '5' => {
                        return c as u8 - b'0';
                    }
                    _ => {}
                }
            }
        }
    }
}

A renderização usa blocos Unicode (██) para células vivas e espaços para células mortas, criando uma visualização clara e compacta. A barra de status mostra informações úteis e os atalhos de teclado disponíveis.

Passo 4: Loop Principal e main.rs Completo

Agora juntamos tudo no loop de simulação principal.

fn main() {
    // Configura terminal em modo raw
    terminal::enable_raw_mode().unwrap();
    let mut stdout = io::stdout();
    execute!(stdout, terminal::EnterAlternateScreen, cursor::Hide).unwrap();

    // Obtém dimensões do terminal
    let (term_largura, term_altura) = terminal::size().unwrap();
    let grid_largura = (term_largura / 2).min(60) as usize; // Cada célula usa 2 caracteres
    let grid_altura = (term_altura - 2).min(30) as usize;   // Reserva 2 linhas para status

    // Menu de seleção
    let escolha = menu_selecao(&mut stdout);
    let (nome_padrao, padrao) = obter_padrao(escolha);

    // Cria e inicializa o grid
    let mut grid = Grid::novo(grid_largura, grid_altura);
    if padrao.is_empty() {
        grid.aleatorio(0.3);
    } else {
        // Centraliza o padrão no grid
        let offset_x = grid_largura / 2 - 5;
        let offset_y = grid_altura / 2 - 5;
        inserir_padrao(&mut grid, &padrao, offset_x, offset_y);

        // Para glider, adiciona vários para visualização mais interessante
        if escolha == 1 {
            inserir_padrao(&mut grid, &padrao, 5, 5);
            inserir_padrao(&mut grid, &padrao, 10, 15);
            inserir_padrao(&mut grid, &padrao, grid_largura - 10, 3);
        }
    }

    let mut geracao: u64 = 0;
    let mut velocidade_ms: u64 = 100;
    let mut pausado = false;
    let mut ultimo_tick = Instant::now();

    execute!(stdout, terminal::Clear(ClearType::All)).unwrap();

    // Loop de simulação
    loop {
        // Processa entrada do teclado
        if event::poll(Duration::from_millis(10)).unwrap() {
            if let Event::Key(KeyEvent { code, .. }) = event::read().unwrap() {
                match code {
                    KeyCode::Char('q') | KeyCode::Esc => break,
                    KeyCode::Char(' ') => pausado = !pausado,
                    KeyCode::Char('+') | KeyCode::Char('=') => {
                        velocidade_ms = velocidade_ms.saturating_sub(20).max(20);
                    }
                    KeyCode::Char('-') => {
                        velocidade_ms = (velocidade_ms + 20).min(500);
                    }
                    KeyCode::Char('r') => {
                        // Reinicia com o mesmo padrão
                        grid = Grid::novo(grid_largura, grid_altura);
                        if padrao.is_empty() {
                            grid.aleatorio(0.3);
                        } else {
                            let ox = grid_largura / 2 - 5;
                            let oy = grid_altura / 2 - 5;
                            inserir_padrao(&mut grid, &padrao, ox, oy);
                        }
                        geracao = 0;
                    }
                    KeyCode::Enter if pausado => {
                        // Avança uma geração no modo pausado
                        grid = grid.proxima_geracao();
                        geracao += 1;
                    }
                    _ => {}
                }
            }
        }

        // Atualiza a simulação no ritmo definido
        if !pausado && ultimo_tick.elapsed() >= Duration::from_millis(velocidade_ms) {
            grid = grid.proxima_geracao();
            geracao += 1;
            ultimo_tick = Instant::now();
        }

        // Renderiza
        renderizar(&mut stdout, &grid, geracao, velocidade_ms, pausado);
    }

    // Restaura o terminal
    execute!(stdout, cursor::Show, terminal::LeaveAlternateScreen).unwrap();
    terminal::disable_raw_mode().unwrap();

    println!("Simulação encerrada após {} gerações.", geracao);
    println!("Padrão: {}", nome_padrao);
}

O loop principal segue o padrão clássico de game loop: processar entrada, atualizar estado, renderizar. O modo pausado permite avançar geração por geração com Enter, ideal para estudar o comportamento dos padrões. As teclas + e - permitem ajustar a velocidade em tempo real.

Como Executar

Compile e execute o projeto:

cargo run

Controles durante a simulação:

  • Espaço: Pausar/retomar a simulação
  • Enter (quando pausado): Avançar uma geração
  • + / -: Aumentar/diminuir a velocidade
  • R: Reiniciar com o mesmo padrão
  • Q ou Esc: Sair

Saída esperada:

 Geração: 42 | População: 127 | Velocidade: 100ms | RODANDO | [Espaço]=Pausar [+/-]=Velocidade [Q]=Sair
                    ██
                ██    ██
                ██    ██
                    ██
        ██          ██          ██
      ██  ██                  ██  ██
        ██          ██          ██
                    ██
                ██    ██
                ██    ██
                    ██

Desafios para Expandir

  1. Editor de padrões – Adicione um modo de edição onde o usuário pode mover um cursor pelo grid e alternar células com a barra de espaço antes de iniciar a simulação.

  2. Salvar e carregar estados – Implemente exportação do grid no formato RLE (Run Length Encoded), padrão da comunidade de Game of Life, permitindo carregar milhares de padrões disponíveis online.

  3. Grid infinito – Substitua o grid fixo por um HashSet<(i64, i64)> que armazena apenas as células vivas, permitindo padrões que se expandem indefinidamente sem limite de memória prático.

  4. Cores por idade – Colora as células baseando-se em quantas gerações estão vivas consecutivamente, criando um efeito visual de gradiente que mostra a “história” da simulação.

  5. Algoritmo HashLife – Implemente o algoritmo HashLife de Bill Gosper para simular padrões astronômicos (bilhões de gerações) em tempo constante usando memoização recursiva.

Veja Também