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
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.
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.
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.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.
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
- Vec e Coleções – Vetores bidimensionais usados para o grid
- Arrays na Biblioteca Padrão – Fundamentos de arrays em Rust
- Threads em Rust – Paralelismo para grids maiores
- Medição de Tempo – Controle temporal do loop de simulação
- Rust para Jogos – Ecossistema de jogos e simulações em Rust