[15] Construindo um jogo Snake usando a biblioteca Gloss (parte 4)

13 minute read

Parece que estamos chegando ao fim desta série. Já temos a mecânica praticamente pronta, só restando a checagem de colisão da serpente com o próprio corpo. Depois disso, ainda quero adicionar algumas coisas: um relógio e a contagem de comidas consumidas. Depois disso, uma tela final de jogo mostrando quanto tempo a partida levou e quantas comidas foram consumidas.

Checagem de fim de jogo

Para checar a colisão da cobra com o próprio corpo é preciso apenas checar a localização da cabeça quando a serpente tentar se movimentar. Lembre-se que a cabeça é de fato a única coisa que se movimenta, então basta fazer a checagem com a cabeça. Entretanto, precisamos adicionar uma forma de sinalizar que o jogo finalizou. Para isto, vamos adicionar um novo campo no tipo Mundo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
data Mundo = Estado {
    cobra :: [Pos]
  , direcao :: Direcao
  , contFrames :: Int
  , randomGen :: StdGen
  , comida :: Pos
  , modo :: Modo   -- adicionado
} deriving Show

mundoInicial :: Mundo
mundoInicial = Estado {
    cobra = [(0,0), (0,-1), (0,-2)]
  , direcao = Parado
  , contFrames = 0
  , randomGen = mkStdGen 0
  , comida = (0,0)
  , modo = Inicio   -- adicionado
}

Nós já tínhamos criado o tipo Modo anteriormente, mas estamos usando-o apenas agora. Esse novo campo, chamado modo, vai armazenar o valor correspondente para sinalizar em que modo de jogo estamos. Acredito que seus construtores tenham nomes sugestivos, não vou me preocupar em explicar aqui.

Essa mudança gera alguns erros de compilação, então vamos endereçá-los aqui antes de qualquer outra coisa:

1
2
3
desenhaComida :: Mundo -> Picture
desenhaComida (Estado _ _ _ _ (x,y) _) =   -- modificado
  translate (fromIntegral x * tamSegmt) (fromIntegral y * tamSegmt) $ color red $ circleSolid (tamSegmt / 2 + 1)

Modificada a linha 2 adicionando um _ depois da tupla (x,y) para poder comportar o novo valor do Mundo.

Checando colisão com o corpo

Agora que temos como sinalizar que o jogo deve ser encerrado, faremos a checagem da colisão da cabeça com o resto do corpo:

1
2
3
4
5
6
7
8
atualizaMundo :: Float -> Mundo -> Mundo
atualizaMundo _ est@(Estado cob dir fra rand com modo)   -- modificado
    | fra `mod` acaoFrames == 0 = Estado novaCobra dir (fra + 1) novoRandom novaComida
    | head novaCobra `elem` tail novaCobra = est { modo = GameOver }   -- adicionado
    | otherwise = est {contFrames = fra + 1}
    where
        novaCobra = atualizaCobra cob dir com
        (novaComida, novoRandom) = atualizaComida rand com novaCobra

Observe a linha 4 que adicionamos para fazer a checagem. Caso a cabeça da cobra esteja na mesma posição de algum outro elemento do corpo da cobra significa que a cobra colidiu com o próprio corpo. Sinalizamos essa situação atualizando o modo para o valor GameOver.

Agora precisamos modificar mais uma vez a função, para adicionar código que mude o comportamento do jogo de acordo com cada modo.

1
2
3
4
5
6
7
8
9
10
atualizaMundo :: Float -> Mundo -> Mundo
atualizaMundo _ est@(Estado cob dir fra rand com modo)
    | modo == Inicio && dir /= Parado = est { modo = Jogando }   -- adicionado
    | modo == Inicio || modo == GameOver = est   -- adicionado
    | modo == Jogando && fra `mod` acaoFrames == 0 = Estado novaCobra dir (fra + 1) novoRandom novaComida modo   -- modificado
    | modo == Jogando && head novaCobra `elem` tail novaCobra = est { modo = GameOver }
    | otherwise = est {contFrames = fra + 1}
    where
        novaCobra = atualizaCobra cob dir com
        (novaComida, novoRandom) = atualizaComida rand com novaCobra

Desejamos transicionar de Inicio para Jogando assim que o jogador movimentar a cobra (linha 3). Caso estejamos nos estados Inicio ou GameOver (linha 4), o estado do jogo não deve ser alterado. Caso o modo seja Jogando e a contagem de frames indique que é hora de o jogo realizar alguma ação (linha 5), deve-se aplicar a alteração apropriada. Caso o modo seja Jogando e a cabeça da cobra esteja sobre seu corpo (linha 6), o jogo deve transicionar para o modo GameOver. Caso nenhum dos casos anteriores seja verdadeiro, a função fará apenas a contagem dos frames (linha 7). IMPORTANTE: A ordem dessas linhas importa! O programa vai checar as condições em ordem, de cima pra baixo, e vai realizar a ação da primeira condição verdadeira.

Estamos chegando perto do fim. Agora que já detectamos o fim do jogo, podemos desenhar informações interessantes quando o jogo finalizar. Vamos aproveitar e coletar essas informações antes de desenhá-las.

Registrando comidas consumidas e o tempo passado

O tempo passado nós obtemos na função atualizaMundo. É justamente o primeiro argumento da função, que optamos por não utilizar até agora. Importante salientar que o valor Float que recebemos é o tempo passado desde a última chamada da função. Devemos então acumular esse valor no estado do programa. Vamos às mudanças necessárias:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
data Mundo = Estado {
    cobra :: [Pos]
  , direcao :: Direcao
  , contFrames :: Int
  , randomGen :: StdGen
  , comida :: Pos
  , modo :: Modo
  , tempo :: Float   --adicionado
} deriving Show

mundoInicial :: Mundo
mundoInicial = Estado {
    cobra = [(0,0), (0,-1), (0,-2)]
  , direcao = Parado
  , contFrames = 0
  , randomGen = mkStdGen 0
  , comida = (0,0)
  , modo = Inicio
  , tempo = 0.0   --adicionado
}

desenhaComida :: Mundo -> Picture
desenhaComida (Estado _ _ _ _ (x,y) Jogando _) =    --modificado
  translate (fromIntegral x * tamSegmt) (fromIntegral y * tamSegmt) $ color red $ circleSolid (tamSegmt / 2 + 1)
desenhaComida _ = Blank

Para registrar o tempo passado precisamos modificar a função atualizaMundo. Uma possibilidade é essa:

1
2
3
4
5
6
7
8
9
10
atualizaMundo :: Float -> Mundo -> Mundo
atualizaMundo deltaT est@(Estado cob dir fra rand com modo temp)   -- modificado
    | modo == Inicio && dir /= Parado = est { modo = Jogando, comida = novaComida, randomGen = novoRandom, tempo = temp + deltaT }  -- modificado
    | modo == Inicio || modo == GameOver = est
    | modo == Jogando && fra `mod` acaoFrames == 0 = Estado novaCobra dir (fra + 1) novoRandom novaComida modo (temp + deltaT)   -- modificado
    | modo == Jogando && head novaCobra `elem` tail novaCobra = est { modo = GameOver }
    | otherwise = est {contFrames = fra + 1, tempo = temp + deltaT}   -- modificado
    where
        novaCobra = atualizaCobra cob dir com
        (novaComida, novoRandom) = atualizaComida rand com novaCobra

Como pode ser visto nas linhas sinalizadas, adicionamos a lógica para contabilizar a passagem do tempo. Obtemos o valor atual casando padrão na linha 2 e acumulando com o valor de deltaT que recebemos como primeiro argumento da função. Agora precisamos encontrar um jeito de mostrar esse relógio na tela. Podemos adicionar mais uma função responsável apenas por desenhar esse tempo e incluir a sua chamada na função desenhaMundo. Vamos ver como a função desenhaRelogio

1
2
desenhaRelogio :: Mundo -> Picture
desenhaRelogio (Estado _ _ _ _ _ modo temp) = translate (-(tamJanela / 2 - 10)) (tamJanela / 2 - 20) (scale 0.1 0.1 (text $ printf "%.2f s" temp))

Nesta função temos a chamada de três funções novas, text, printf e scale. A função text desenha uma String na tela. Usamos a função printf para formatar a String que queremos desenhar. Ao passar o valor %.2f, estamos dizendo que vamos passar um valor de ponto flutuante e que queremos apenas exibir duas casas decimas. Por fim, também usamos a função scale, que reduz o tamanho do texto para 10% do seu tamanho original. A função translate apenas posiciona o texto no canto superior esquerdo da tela.

Aproveitando essa nova adição, vamos aproveitar e fazer um mini refactoring. Vamos adicionar uma nova função para desenhar a cobra e modificar a função que desenha o mundo para usar functores e aplicativos! Esse assunto é um pouco mais avançado, e convido você a ler sobre eles:

Utilizando os conceitos que você acabou de ler, podemos criar uma lista de funções e aplicar ao nosso mundo, obtendo uma lista de resultados. Vamos ver o que precisamos mexer para tornar isso possível:

1
2
3
4
5
desenhaCobra :: Mundo -> Picture
desenhaCobra m = pictures $ map desenhaSegmento (cobra m)

desenhaMundo :: Mundo -> Picture
desenhaMundo m = pictures $ [desenhaComida,  desenhaCobra, desenhaRelogio] <*> pure m

Adicionamos a função desenhaCobra, capaz de receber um mundo e desenhar uma cobra. Na função desenhaMundo, agora, basta adicionarmos todas as funções que desenham o nosso mundo dentro de uma lista e usamos o operador <*> para aplicar ao nosso mundo. O resultado dessa operação são as três Picture que cada uma dessas funções retorna. Ficou bonito, não ficou? :)

Exibindo estatísticas após o fim da partida

Apenas para finalizar, que tal contabilizarmos a quantidade de comidas consumidas? Vamos ver como podermos fazer isso:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
data Mundo = Estado {
    cobra :: [Pos]
  , direcao :: Direcao
  , contFrames :: Int
  , randomGen :: StdGen
  , comida :: Pos
  , modo :: Modo
  , tempo :: Float
  , qtdComidas :: Int   --adicionado
} deriving Show

mundoInicial :: Mundo
mundoInicial = Estado {
    cobra = [(0,0), (0,-1), (0,-2)]
  , direcao = Parado
  , contFrames = 0
  , randomGen = mkStdGen 0
  , comida = (0,0)
  , modo = Inicio
  , tempo = 0.0
  , qtdComidas = 0   --adicionado
}

Precisamos atualizar desenhaRelogio e desenhaComida porque modificamos o tipo Mundo:

1
2
3
4
5
6
desenhaRelogio :: Mundo -> Picture
desenhaRelogio (Estado _ _ _ _ _ modo temp _) = translate (-(tamJanela / 2 - 10)) (tamJanela / 2 - 20) (scale 0.1 0.1 (text $ printf "%.2f s" temp))

desenhaComida :: Mundo -> Picture
desenhaComida (Estado _ _ _ _ (x,y) Jogando _ _) = translate (fromIntegral x * tamSegmt) (fromIntegral y * tamSegmt) $ color red $ circleSolid (tamSegmt / 2 + 1)
desenhaComida _ = Blank

A modificação que realmente faz diferença está abaixo. Precisamos atualizar a função atualizaMundo para contabilizar as comidas consumidas:

1
2
3
4
5
6
7
8
9
10
11
atualizaMundo :: Float -> Mundo -> Mundo
atualizaMundo deltaT est@(Estado cob dir fra rand com modo temp qtdCom)
    | modo == Inicio && dir /= Parado = est { modo = Jogando, comida = novaComida, randomGen = novoRandom, tempo = temp + deltaT }  
    | modo == Inicio || modo == GameOver = est
    | modo == Jogando && fra `mod` acaoFrames == 0 = Estado novaCobra dir (fra + 1) novoRandom novaComida modo (temp + deltaT) novoQtdComida
    | modo == Jogando && head novaCobra `elem` tail novaCobra = est { modo = GameOver }
    | otherwise = est {contFrames = fra + 1, tempo = temp + deltaT}
    where
        novaCobra = atualizaCobra cob dir com
        (novaComida, novoRandom) = atualizaComida rand com novaCobra
        novoQtdComida = if novaComida /= com then qtdCom + 1 else qtdCom

Observe a linha 11, nela comparamos para saber se a nova comida é diferente da comida anterior. Em caso positivo, quer dizer que a nova comida foi gerada porque a anterior foi consumida. Nesse caso, incrementamos o valor de qtdCom e a esse novo valor damos o nome de novoQtdComida. Usamos esse valor na linha 5.

Só nos resta agora definir mais uma função, que será capaz de desenhar as estatística de fim de jogo:

1
2
3
4
5
6
desenhaEstatisticas :: Mundo -> Picture
desenhaEstatisticas (Estado _ _ _ _ (x,y) GameOver tempo comidas) =
  pictures [translate (-150.0) 100.0 (scale 0.4 0.4 (text "Game Over"))
          , translate (-100.0) 60 (scale 0.2 0.2 (text $ "Tempo: " ++ printf "%.2f s" tempo))
          , translate (-100.0) 30 (scale 0.2 0.2 (text $ "Comidas: " ++ show comidas))]
desenhaEstatisticas _ = Blank

Em três linhas diferentes escrevemos a String “Game Over”, o tempo total de jogo e a quantidade de comidas consumidas. Utilizamos funções bem conhecidas para atingir esse objetivo. Se ficou em dúvida, dá uma revisada no nas postagens anteriores. Ufa, terminamos!

Resumo

Pois bem, finalmente terminamos nosso Snake. Quer dizer, falta uma coisa só, que convido você a tentar fazer: como o jogador vence o jogo? No jogo original, caso ele consiga ocupar todo o mapa com a cobra, ele vence o jogo. Não implementamos isso aqui. Que tal você tentar checar essa condição?

Nosso jogo tem uma serpente, que se move de acordo com as regras do jogo original. Temos comidas aleatórias, contagem de tempo e comidas consumidas. Ao fim, o jogo exibe uma tela simples de estatísticas. Conseguimos exercitar muitos conceitos da linguagem Haskell e do paradigma funcional. Espero que te ajude na sua trajetória de estudos.

O código que é resultado desta postagem pode ser encontrado no Github: https://github.com/emanoelbarreiros/hsnake/tree/parte4

Updated: