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