Snake Game

Eu estava em dúvida em que projeto fazer neste ponto, foi difícil pensar em algo e, porque não algo que eu nunca fiz?

Então vamos lá fazer o "jogo da cobrinha", vamos começar criando o projeto, usando o comando cargo new snake-game.

Teremos a estrutura padrão do projeto:

├── Cargo.toml └── src └── main.rs

Vamos adicionar um arquivo chamado lib.rs na pasta src, este arquivo sera usado para declarar os nossos módulos. Em seguida criamos um arquivo chamado "ponto.rs" e nele iremos criar uma struct para as localizações no nosso jogo, vamos criar uma implementação a essa struct para facilitar a instânciação dessa strutc.

#![allow(unused)] fn main() { pub struct Ponto { pub x: usize, pub y: usize } impl Ponto { pub fn new(x: usize, y: usize) -> Self { Self { x, y } } } }

Para que este arquivo seja reconhecido no projeto, vamos adicionar no nosso arquivo lib.rs a seguinte linha pub mod point;. Note que tanto a struct quanto seus atributos e a implementação do método new estão com a palavra pub, que faz eles serem visíveis fora desse módulo.

Vamos printar o campo onde a cobrinha ira andar, e para testar vamos adicionar um ponto nesse tabuleiro.

fn main() { let ponto = Ponto::new(7, 7); let (x, y) = (15, 15); for x in 0..x { for y in 0..y { if ponto == (x, y) { print!("# ") } else { print!("- "); } } println!(); } }

Note que comparamos a nossa struct Ponto, com uma tupla de (x, y), para isso ser possível, precisamos implementar uma trait chamada PartialEq a implementação para isso é relativamente simples. A trait recebe um parâmetro genérico na implementação vamos falar que esse parâmetro genérico é uma tupla (usize, usize). E a partir dai implementamos nossa comparação.

#![allow(unused)] fn main() { impl PartialEq<(usize, usize)> for Ponto { fn eq(&self, other: &(usize, usize)) -> bool { self.x == other.0 && self.y == other.1 } } }

Agora quando rodarmos o projeto com cargo run, teremos um tabuleiro no console com um ponto na posição (7, 7).

Compiling snake-game v0.1.0 (/home/paulo.bezerra/workspace/ws-rust/rust4noobs/projects/snake-game) Finished dev [unoptimized + debuginfo] target(s) in 0.31s Running `target/debug/snake-game` - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

Agora vamos criar a struct da nossa cobrinha, para isso vamos adicionar a cabeça - que é um ponto - e uma lista de pontos para o corpo. Criamos o arquivo "cobra.rs" e adicionamos o pub mod cobra no arquivo lib.rs, e no arquivo "cobra.rs" adicionamos a struct.

#![allow(unused)] fn main() { pub struct Cobra { pub cabeca: Ponto, pub corpo: Vec<Ponto>, } impl Default for Cobra { fn default() -> Self { Self { cabeca: Ponto::new(7, 7), corpo: vec![Ponto::new(6,7), Ponto::new(5,7)] } } } }

A implementação da trait default serve para termos um valor padrão para a struct. Vamos separar a nossa função de desenhar o tabuleiro e vamos passar uma referência para a struct da cobra, então com base nos dados passados ali vamos desenhar a nossa cobra.

#![allow(unused)] fn main() { fn print_board(cobra: &Cobra) { let (x, y) = (15, 15); for y in 0..y { for x in 0..x { if cobra.cabeca == (x, y) { print!("0 ") } else if cobra.corpo.contains(&Ponto::new(x, y)) { print!("# "); } else { print!("- "); } } println!(); } } }

Temos a função e agora é só chamar ela na nossa função main.

fn main() { print_board(&Cobra::default()) }

Após executar o comando cargo run temos o output:

Finished dev [unoptimized + debuginfo] target(s) in 0.00s Running `target/debug/snake-game` - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # # 0 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

Agora temos a cabeça e o corpo, precismos começar a definir uma direção que a cobra irá seguir e movimentar o corpo da cobra.

Para isso criamos um enumarado de direções, seguimos o mesmo passo a passo, criamos um arquivo "direcao.rs" e adicionamos no arquivo lib.rs a declaração do módulo pub mob direcao.

Então adicionamos as 4 direções possíveis ao nosso enum.

#![allow(unused)] fn main() { #[derive(Clone, Copy)] pub enum Direcao { Cima, Baixo, Direita, Esquerda, } impl Default for Direcao { fn default() -> Self { Self::Direita } } }

Criamos o enum já implementando a trait default para nos auxiliar, como o padrão de início da cobra sempre vai ser para a direita, colocamos o retorno do método o valor Self::Direita. Já derivamos as traits, Clone e Copy para não precisar passar esse enum como referência todas às vezes.

Agora na nossa struct da cobra, vamos adicionar o atributo da direção.

#![allow(unused)] fn main() { pub struct Cobra { pub cabeca: Ponto, pub corpo: Vec<Ponto>, direcao: Direcao } impl Default for Cobra { fn default() -> Self { Self { cabeca: Ponto::new(7, 7), corpo: vec![Ponto::new(6,7), Ponto::new(5,7)], direcao: Default::default() } } } }

Agora temos um modo de saber para qual direção a cobra está andando.

Na nossa struct Ponto vamos adicionar a função para alterar o valor do ponto.

#![allow(unused)] fn main() { impl Point { ... pub fn alterar(&mut self, direcao: Direcao) { match Direcao { Direcao::Right => self.x += 1, Direcao::Left => self.x -= 1, Direcao::Up => self.y -= 1, Direcao::Down => self.y += 1, } } } }

Vamos aproveitar e adicionar testes unitários para o método de alterar:

#![allow(unused)] fn main() { #[cfg(test)] mod ponto_tests { use super::*; #[test] fn alterar_para_cima() { let mut ponto = Ponto::new(1, 1); ponto.alterar(Direcao::Cima); assert_eq!(Ponto::new(1, 0), ponto); } #[test] fn alterar_para_baixo() { let mut ponto = Ponto::new(1, 1); ponto.alterar(Direcao::Baixo); assert_eq!(Ponto::new(1, 2), ponto); } #[test] fn alterar_para_direita() { let mut ponto = Ponto::new(1, 1); ponto.alterar(Direcao::Direita); assert_eq!(Ponto::new(2, 1), ponto); } #[test] fn alterar_para_esquerda() { let mut ponto = Ponto::new(1, 1); ponto.alterar(Direcao::Esquerda); assert_eq!(Ponto::new(0, 1), ponto); } } }

Agora iremos adicionar a lógica para a cobra se mover, precisaremos de um método para mover a cabeça que é quem vai definir se o movimento é valido, se vai bater na parede, se vamos alterar a direção e já vamos adicionar os testes que consiste em, encerrar o jogo caso bata na parede, validar a posição dos pontos após algum movimento, etc.

#![allow(unused)] fn main() { impl Cobra { pub fn passo(&mut self, tabuleiro: (usize, usize)) -> Result<(), &'static str> { let posicao_anterior_cabeca = self.cabeca; self.mover_cabeca(&tabuleiro)?; self.mover_corpo(posicao_anterior_cabeca); Ok(()) } pub fn alterar_direcao(&mut self, direcao: Direcao) { self.direcao = direcao; } fn mover_cabeca(&mut self, board: &(usize, usize)) -> Result<(), &'static str> { match self.direcao { Direcao::Cima if self.cabeca.y == 0 => Err("fim de jogo, esbarrou na parede de cima"), Direcao::Baixo if self.cabeca.y >= board.1 => Err("fim de jogo, esbarrou na parede de baixo"), Direcao::Esquerda if self.cabeca.x == 0 => Err("fim de jogo, esbarrou na parede da esquerda"), Direcao::Direita if self.cabeca.x >= board.0 => Err("fim de jogo, esbarrou na parede da direita"), _ => { self.cabeca.alterar(self.direcao); Ok(()) } } } fn mover_corpo(&mut self, posicao_anterior_cabeca: Ponto) { let corpo = &mut self.corpo; let mut posicao_anterior = posicao_anterior_cabeca; for ponto in corpo.iter_mut() { std::mem::swap(&mut posicao_anterior, ponto); } } } ... #[cfg(test)] mod cobra_tests { use super::*; #[test] fn mover_cabeca_cobra_para_direita_no_tabuleiro_deve_mover_com_sucesso() { let mut cobra = Cobra { cabeca: Ponto::new(7, 7), corpo: vec![], direcao: Direcao::Right, }; let expected_point = Ponto::new(8, 7); cobra.mover_cabeca(&(8, 8)).unwrap(); assert_eq!(expected_point, cobra.cabeca); } #[test] fn mover_cabeca_cobra_para_esquerda_no_tabuleiro_deve_mover_com_sucesso() { let mut cobra = Cobra { cabeca: Ponto::new(7, 7), corpo: vec![], direcao: Direcao::Left, }; let expected_point = Ponto::new(6, 7); cobra.mover_cabeca(&(8, 8)).unwrap(); assert_eq!(expected_point, cobra.cabeca); } #[test] fn mover_cabeca_cobra_para_cima_no_tabuleiro_deve_mover_com_sucesso() { let mut cobra = Cobra { cabeca: Ponto::new(7, 7), corpo: vec![], direcao: Direcao::Up, }; let expected_point = Ponto::new(7, 6); cobra.mover_cabeca(&(8, 8)).unwrap(); assert_eq!(expected_point, cobra.cabeca); } #[test] fn mover_cabeca_cobra_para_baixo_no_tabuleiro_deve_mover_com_sucesso() { let mut cobra = Cobra { cabeca: Ponto::new(7, 7), corpo: vec![], direcao: Direcao::Down, }; let expected_point = Ponto::new(7, 8); cobra.mover_cabeca(&(8, 8)).unwrap(); assert_eq!(expected_point, cobra.cabeca); } #[test] #[should_panic(expected = "fim de jogo, esbarrou na parede da direita")] fn mover_cabeca_cobra_para_direita_no_tabuleiro_deve_esbarrar_na_parede() { let mut cobra = Cobra { cabeca: Ponto::new(7, 7), corpo: vec![], direcao: Direcao::Right, }; cobra.mover_cabeca(&(7, 7)).unwrap(); } #[test] #[should_panic(expected = "fim de jogo, esbarrou na parede da esquerda")] fn mover_cabeca_cobra_para_esquerda_no_tabuleiro_deve_esbarrar_na_parede() { let mut cobra = Cobra { cabeca: Ponto::new(0, 7), corpo: vec![], direcao: Direcao::Left, }; cobra.mover_cabeca(&(7, 7)).unwrap(); } #[test] #[should_panic(expected = "fim de jogo, esbarrou na parede de baixo")] fn mover_cabeca_cobra_para_baixo_no_tabuleiro_deve_esbarrar_na_parede() { let mut cobra = Cobra { cabeca: Ponto::new(0, 7), corpo: vec![], direcao: Direcao::Down, }; cobra.mover_cabeca(&(7, 7)).unwrap(); } #[test] #[should_panic(expected = "fim de jogo, esbarrou na parede de cima")] fn mover_cabeca_cobra_para_cima_no_tabuleiro_deve_esbarrar_na_parede() { let mut cobra = Cobra { cabeca: Ponto::new(0, 0), corpo: vec![], direcao: Direcao::Up, }; cobra.mover_cabeca(&(7, 7)).unwrap(); } #[test] fn mover_cobra_inteira_para_a_direita_deve_mover() { let tabuleiro = (15, 15); let mut cobra = Cobra { cabeca: Ponto::new(7, 7), corpo: vec![Ponto::new(6, 7)], direcao: Direcao::Right, }; cobra.passo(board).unwrap(); assert_eq!(Ponto::new(8, 7), cobra.cabeca); assert_eq!(Ponto::new(7, 7), *cobra.corpo.first().unwrap()); } #[test] fn mover_cobra_inteira_para_a_esquerda_deve_mover() { let tabuleiro = (15, 15); let mut cobra = Cobra { cabeca: Ponto::new(7, 7), corpo: vec![Ponto::new(6, 7)], direcao: Direcao::Left, }; cobra.passo(board).unwrap(); assert_eq!(Ponto::new(6, 7), cobra.cabeca); assert_eq!(Ponto::new(7, 7), *cobra.corpo.first().unwrap()); } #[test] fn mover_cobra_inteira_para_cima_deve_mover() { let tabuleiro = (15, 15); let mut cobra = Cobra { cabeca: Ponto::new(7, 7), corpo: vec![Ponto::new(6, 7)], direcao: Direcao::Up, }; cobra.passo(board).unwrap(); assert_eq!(Ponto::new(7, 6), cobra.cabeca); assert_eq!(Ponto::new(7, 7), *cobra.corpo.first().unwrap()); } #[test] fn mover_cobra_inteira_para_baixo_deve_mover() { let tabuleiro = (15, 15); let mut cobra = Cobra { cabeca: Ponto::new(7, 7), corpo: vec![Ponto::new(6, 7)], direcao: Direcao::Down, }; cobra.passo(board).unwrap(); assert_eq!(Ponto::new(7, 8), cobra.cabeca); assert_eq!(Ponto::new(7, 7), *cobra.corpo.first().unwrap()); } } }

Notem o método de mover a cabeça da cobra:

#![allow(unused)] fn main() { fn mover_cabeca(&mut self, board: &(usize, usize)) -> Result<(), &'static str> { match self.direcao { Direcao::Cima if self.cabeca.y == 0 => Err("fim de jogo, esbarrou na parede de cima"), Direcao::Baixo if self.cabeca.y >= board.1 => Err("fim de jogo, esbarrou na parede de baixo"), Direcao::Esquerda if self.cabeca.x == 0 => Err("fim de jogo, esbarrou na parede da esquerda"), Direcao::Direita if self.cabeca.x >= board.0 => Err("fim de jogo, esbarrou na parede da direita"), _ => { self.cabeca.alterar(self.direcao); Ok(()) } } } }

Temos nessa implementação o uso de um if que segue o valor do enum, afinal o que é isso?

Isso faz parte do Pattern Match, é algo que chamamos de guards, do modo em que essa implementação é feita, temos duas validações para cair nesse ponto, o enum deve bater ali e a condição deve ser verdadeira, caso uma das duas condições falhe ele segue para o próximo match.

Na função de mover o corpo temos a lógica para mover o restante da cobra, guardamos a posição do ponto antes de ser alterada e fazemos o próximo item a ser iterado a obter essa posição. Para isso usamos o método da biblioteca padrão do Rust, swap, esse método troca o valor de duas referências que são passadas.

#![allow(unused)] fn main() { fn mover_corpo(&mut self, posicao_anterior_cabeca: Ponto) { let corpo = &mut self.corpo; let mut posicao_anterior = posicao_anterior_cabeca; for ponto in corpo.iter_mut() { std::mem::swap(&mut posicao_anterior, ponto); } } }

Agora temos a lógica de mover a cobra, mas temos um problema nela, no método de alterar a direção, não temos uma validação para saber se o jogador, selecionou a opção de direção contraria da que a cobra esta seguindo, vamos adicionar agora.

No nosso enum de direção, vamos adicionar um método para pegar a direção contraria.

#![allow(unused)] fn main() { impl Direcao { pub fn direcao_inversa(outro: Self) -> Self { match outro { Self::Cima => Self::Baixo, Self::Baixo => Self::Cima, Self::Direita => Self::Esquerda, Self::Esquerda => Self::Direita } } } }

Deixo o teste deste método por sua conta.

E agora no nosso método de alterar a direção, faremos a validação, também deixo por sua conta esta alteração, e os testes da mesma.

Agora que temos o tabuleiro do jogo sendo desenhado, e temos a movimentação da cobra programada, vamos adicionar o petisco que iremos ter que pegar no jogo. O petisco é um ponto, então não precisamos criar outra struct para ela, apenas vamos gerar um ponto aleatório e fazer o nosso render renderiza-lo.

#![allow(unused)] fn main() { fn gerar_petisco(cobra: &Cobra, tabuleiro: &(usize, usize)) -> Point { let mut petisco; loop { let x = rand::thread_rng().gen_range(0..=tabuleiro.0 - 1); let y = rand::thread_rng().gen_range(0..=tabuleiro.1 - 1); petisco = Point::new(x, y); if cobra.cabeca != petisco && !cobra.corpo.contains(&petisco) { break; } } petisco } }

Para esse rand funcionar precisamos ir em nosso Cargo.toml e adicionar a seguinte dependência rand = "0.8.5" logo abaixo do [dependencies], nesse método temos validações para não gerar um petisco em cima da cobra, ou seja, se o valor aleatório cair na cabeça ou em alguma parte do corpo da cobra, outro valor sera gerado. Quando o valor respeitar essa condição o loop para.

Agora precisamos aumentar o tamanho da cobra, para isso adicionamos um método que ira adicionar um ponto, no fim do corpo da cobra.

#![allow(unused)] fn main() { impl Cobra { ... pub fn aumentar_tamanho(&mut self) { let ultimo = self.body.last().unwrap().clone(); self.body.push(ultimo); } } }

Para testar esse método é interessante, validarmos o tamanho do corpo e se a posição do ponto adicionado, é igual à posição do último ponto anterior após a cobra se mover.

Agora temos que fazer o jogo funcionar, estamos quase lá.

Vamos criar um arquivo jogo onde teremos a struct Jogo. Aquele mesmo processo de sempre, cria o arquivo, adiciona na lib.rs.

A struct é a mais simples possível, ela é apenas.

#![allow(unused)] fn main() { struct Jogo; }

Então vamos alterar o método que desenha o tabuleiro para gerar uma String, vamos usa-la para desenhar o tabuleiro inteiro de uma vez e também vamos movê-la para a struct Jogo.

#![allow(unused)] fn main() { impl Jogo { fn gerar_tabuleuro(cobra: &Cobra, petisco: &Ponto, tabuleiro: &(usize, usize)) -> String { let mut buffer = String::new(); for y in 0..tabuleiro.1 { for x in 0..tabuleiro.0 { if cobra.cabeca == (x, y) { buffer.push_str("0 ") } else if cobra.corpo.contains(&Ponto::new(x, y)) { buffer.push_str("# "); } else if *petisco == (1x, y) { buffer.push_str("+ "); } else { buffer.push_str("- "); } } buffer.push('\n'); } buffer } } }

Agora que fizemos essa alteração, vamos jogar o gerador do petisco para essa mesma struct.

#![allow(unused)] fn main() { impl Jogo { ... fn gerar_petisco(cobra: &Cobra, tabuleiro: &(usize, usize)) -> Point { let mut petisco; loop { let x = rand::thread_rng().gen_range(0..=tabuleiro.0 - 1); let y = rand::thread_rng().gen_range(0..=tabuleiro.1 - 1); petisco = Point::new(x, y); if cobra.cabeca != petisco && !cobra.corpo.contains(&petisco) { break; } } petisco } } }

E estamos quase lá, falta apenas um loop infinito, onde, movemos a cobra, limpamos a tela anterior, redesenhamos a tela e capturamos a tecla acionada. Para facilitar o processo vamos adicionar mais uma dependência no arquivo Cargo.toml, sera dependência termion.

Então nosso arquivo ficará parecido com isso:

[package] name = "snake-game" version = "0.1.0" edition = "2021" [dependencies] rand = "0.8.5" termion = "1.5.6"

Vamos adicionar o método estático na struct Jogo que ira fazer a "mágica" acontecer.

#![allow(unused)] fn main() { impl Jogo { pub fn run() -> Result<(), &'static str> { let mut cobra: Cobra = Default::default(); let tabuleiro = (15, 15); let mut petisco = Self::gerar_petisco(&snake, &tabuleiro); let mut stdin = termion::async_stdin().keys(); loop { if cobra.cabeca == snack { cobra.aumentar_tamanho(); petisco = Self::gerar_petisco(&cobra, &tabuleiro); } else if cobra.corpo.contains(&cobra.cabeca) { return Err("fim de jogo, a cobra bateu nela mesma"); } let tabuleiro_jogo = Self::gerar_tabuleuro(&cobra, &petisco, &tabuleiro); print!( "{}{}{}", termion::clear::All, termion::cursor::Goto(1, 1), termion::cursor::Hide ); println!("{}", tabuleiro_jogo); let stdout = io::stdout().into_raw_mode().unwrap(); let input = stdin.next(); if let Some(Ok(key)) = input { match key { Key::Char('a') | Key::Left => cobra.alterar_direcao(Direcao::Esquerda), Key::Char('w') | Key::Up => cobra.alterar_direcao(Direcao::Cima), Key::Char('s') | Key::Down => cobra.alterar_direcao(Direcao::Baixo), Key::Char('d') | Key::Right => cobra.alterar_direcao(Direcao::Direita), _ => {}, } } stdout.lock().flush().unwrap(); thread::sleep(Duration::from_millis(500)); cobra.passo(tabuleiro)?; } } } }

Esse método agrupa tudo o que nós precisamos, criamos a cobra, criamos o primeiro petisco, definimos o tamanho do tabuleiro e começamos a trabalhar.

Na linha let mut stdin = termion::async_stdin().keys(); criamos um modo assincrono de capturar as teclas digitadas pelo usuário, utilizando a dependencia do termion, assim que entramos no loop, fazemos as primeiras verificações, que são:

  • Validar se a cabeça da cobra está na mesma posição de um petisco
    • Se sim => seu tamanho aumenta e outro petisco é gerado.
    • Se não => valida se a cabeça está na mesma posição de seu corpo
      • Se sim => encerra o jogo com a mensagem de fim de jogo
      • Se não => continua a execução

Então geramos o tabuleiro e armazenamos em uma variável. Com um método do termion, limpamos o terminal, posicionamos o mouse na primeira posição e escondemos o cursor. Logo em sequência desenhamos tabuleiro do jogo. Transformamos a saída em raw mode, lemos uma tecla e caso alguma tecla tenha sido pressionada validamos qual foi, em um match assim alteramos a direção que a cobra esta andando caso necessário. Limpamos a saída, esperamos meio segundo com o método thread::sleep(Duration::from_milis(500)) e então fazemos a cobra dar mais um passo. O processo todo se repete.

Adicionamos a o nosso main a chamada a esse método:

fn main(){ if let Err(msg) = Game::run() { eprintln!("{}", msg) } }

E pronto, temos nosso jogo da cobrinha feito e funcionando, deixo para você os testes finais e adiciono alguns desafios:

  • Faça o jogo pausar
  • Adicione um placar ao jogo
  • Quando a cobra alcançar o tamanho máximo (x * y) mostre uma mensagem de vitória e encerre o jogo