[14] Construindo um jogo Snake usando a biblioteca Gloss (parte 3)

13 minute read

Neste post evoluiremos o código para incluir a geração aleatória da comida e também a checagem de colisão da cabeca com o próprio corpo da serpente. A versão base do código sobre o qual vamos trabalhar hoje pode ser encontrada aqui: https://github.com/emanoelbarreiros/hsnake/tree/parte2.

Aleatoriedade

Estamos acostumados com a geração de números aleatórios em outras linguagens. Basicamente invocamos uma função que nos dá um número aleatório. Às vezes a função nos dá um número aleatório distribuído uniformemente entre 0 e 1. Outras vezes nós conseguimos informar um intervalo e ter o nosso número aleatório dentro desse intervalo. Se invocarmos a mesma função novamente, teremos um novo número aleatório.

É importante salientar que não é possível ter um número realmente aleatório, uma vez que os números são gerados a partir do processamento de uma função. Aleatoriedade real é muito difícil de se conceber. Por mais impressionante que possa parecer, se te pedirem para construir “aleatoriamente” uma sequencia de números não podemos considerar essa sequencia como realmente aleatória. Se fizeres isso várias vezes, provavelmente vai ser possível mostrar que a distribuição desses números não é uniforme.

Como foi dito anteriormente, números aleatórios gerados por computador, na grande maioria dos casos, são gerados de forma previsível, utilizando-se funções matemáticas. Por isso dizemos que os números gerados por computador são pseudo-aleatórios. As funções tentam emular o comportamento de números realmente aleatórios (e.g. produzindo números distribuídos uniformemente no intervalo), mas ainda tem limitações. Existem até serviços na web que prometem fornecer números realmente aleatórios, baseados em ruídos atmosféricos: https://www.random.org/. A partir de agora, quando você ler o termo “aleatório”, ele na realidade significa pseudo-aleatório.

Uma das características das funções que geram números aleatórios em linguagens não-puras, é que sucessivas chamadas à função de geração de números aleatórios vai devolver números diferentes. Você já parou para pensar no que isso significa? Internamente precisa existir um mecanismo que registra chamadas sucessivas à função para que ela possa retornar sempre um número diferente. Isso só é possível porque tais funções tem efeitos colaterais, isto é, além de produzir o número aleatório, ela produz um efeito colateral em alguma variável global, que vai estar disponível na próxima invocação. Desta forma, um novo número é sempre gerado. Como você já deve ter deduzido, isso não pode acontecer em Haskell. Como fazemos então?

Números aleatórios em Haskell

Como não podemos ter os efeitos colaterais comuns em linguagens não puras, a função que gera números aleatórios em Haskell retorna uma tupla, composta pelo número gerado e o próximo gerador de números aleatórios. A função que gera os números deve receber o intervalo para geração dos números como uma tupla, e o gerador. Por baixo dos panos, a função utiliza o gerador, cria um novo e o retorna, para que possamos usar nas chamadas seguintes.

Uma coisa importante também é que a criação do gerador inicial deve acontecer preferencialmente dentro de um contexto de IO, por ser uma operação não-pura. Existe também a possibilidade de criar um gerador a partir de uma operação pura, mas você vai precisar informar a semente para o gerador. Este gerador gerará sempre a mesma sequência de valores, uma vez que a semente será um valor fixo. Utilizando a função apropriada em um contexto de IO você um gerador diferente sempre que rodar o programa novamente. Vamos a um exemplo básico:

1
2
3
4
5
6
7
8
9
10
11
12
import System.Random

run :: IO ()
run = do
    rand <- getStdGen
    let (num1, rand2) = randomR (-100, 100) rand :: (Int, StdGen)
    let (num2, rand3) = randomR (-100, 100) rand2 :: (Int, StdGen)
    let (num3, rand4) = randomR (-100, 100) rand3 :: (Int, StdGen)
    print num1
    print num2
    print num3
    return ()

A linha 5 obtém o gerador padrão. Com isso, sempre que você executar o programa terá um gerador diferente, sem a necessidade de informar uma semente. Como você pode observar, é uma action, então só pode executar dentro de um contexto de IO.

As linhas 6 a 8 invocam a função randomR passando como argumento a tupla (-100, 100) (intervalo para geração dos números) e o gerador. Cada linha dessas retorna uma tupla, composta pelo número em si e o próximo gerador. A linha seguinte precisa usar o novo gerador, caso contrário obterá o mesmo número gerado anteriormente por randomR. Veja que você terá sempre números novos ao rodar o programa. ATENÇÃO: se você estiver rodando a função run dentro do GHCi, você precisa encerrar o GHCi (usando o comando :q) e recarregar o arquivo, recarrega-lo apenas usando o :r (ou :reload) não é suficiente.

Usando números aleatórios no nosso jogo

A primeira coisa que precisamos fazer é criar o gerador e guardar isso em algum lugar. Qual lugar pode ser esse? Sim, vamos guardar ele dentro do nosso mundo. Vejamos como podemos mudar o código da função main para fazer isso funcionar:

1
2
3
4
5
6
7
import System.Random

main :: IO ()
main = do
    let mundo = mundoInicial
    stdGen <- getStdGen
    play (InWindow "hSnake" (tamJanela , tamJanela) (500, 500)) white 60 (mundo { randomGen = stdGen }) desenhaMundo tratarEvento atualizaMundo

Observe a importação na linha 1, ela é importante. Criamos o mundo na linha 5 e o gerador de números aleatórios na linha 6. Ao passar o mundo para a função play atualizamos o mundo com o gerador.

Para isso funcionar, precisamos também mudar a definição do mundo para adicionar um campo que armazene o gerador (não esqueça de acrescentar o import para System.Random no topo do arquivo). Também será preciso atualizar a função atualizaMundo, uma vez que ela também manipula o estado do programa.

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

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

atualizaMundo :: Float -> Mundo -> Mundo
atualizaMundo _ est@(Estado cob dir fra rand) -- adicionado o campo do gerador
    | fra `mod` acaoFrames == 0 = Estado novaCobra dir (fra + 1) rand -- aqui também
    | otherwise = est {contFrames = fra + 1}
    where
        novaCobra = movimentaCobra cob dir

Agora que temos nosso gerador, precisamos implementar a geração da comida.

Gerando a comida

Precisaremos de uma nova função, que vamos chamar de atualizaComida. Esse função é responsável por detectar quando o jogador passa por cima da comida e cria uma nova. Antes de implementarmos a função, precisamos fazer algumas alterações no Mundo:

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

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

A alteração no mundo afeta a função atualizaMundo, pois agora precisamos gerenciar a comida. Vamos ver como isso pode ficar:

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

Veja que agora fazemos uso de uma função chamada atualizaComida. Ela é responsável por checar se a cobra consumiu a comida, e em caso positivo, precisa gerar uma nova comida em uma nova posição. As linhas 2 e 3 foram modificadas porque precisam levar em consideração a comida que agora compõe o nosso mundo. A linha 7 é totalmente nova, é responsável por adicionar a checagem que mencionamos acima sobre a comida (será que a cobra comeu a comida?). Após cada execução da função atualizaMundo, atualizamos o estado com uma novaComida. Importante salientar que a lógica da função atualizaComida vai retornar a mesma comida caso a serpente não tenha consumido a comida. Vamos ver agora uma possibilidade de implementação para esta função.

A função atualizaComida

A função atualizaComida vai checar a posição da cobra e criará uma nova comida caso seja necessário. Caso a serpente não tenha consumido a comida, a função simplesmente retorna a mesma comida que recebeu como argumento. Vamos ao código:

1
2
3
4
5
6
7
8
9
10
atualizaComida :: StdGen -> Pos -> [Pos] -> (Pos, StdGen)
atualizaComida g com cob = if com == head cob
                           then novaComida cob g
                           else (com, g)

novaComida :: [Pos] -> StdGen -> (Pos, StdGen)
novaComida cob gen = if (x, y) `notElem` cob then ((x, y), stdGen3) else novaComida cob stdGen3
                     where
                       (x, stdGen2) = randomR (-limite, limite) gen
                       (y, stdGen3) = randomR (-limite, limite) stdGen2

Vamos analisar seu tipo. Ela recebe:

  • um valor do tipo StdGen, que é o gerador de números aleatórios;
  • um valor do tipo Pos, que representa a posição da comida;
  • um valor do tipo [Pos], que representa a serpente;
  • e devolve uma tupla de Pos (a posição da comida após o processamento) e o novo gerador para ser guardado no Mundo;

Para detectar se a serpente consumiu a comida, precisamos verificar se a cabeça da serpente está ocupando a mesma posição da comida. Em caso positivo, retornamos o resultado da invocação da função auxiliar novaComida, que como o nome sugere, vai criar uma nova comida para a gente. A função precisa garantir apenas que a comida gerada não seja em alguma posição ocupada por um segmento da cobra. Utilizamos o gerador para criar dois valores, um para X e outro para o Y da comida. Na linha 7 checamos com um if a posição que acabamos de gerar, caso ela, por algum azar, tenha caído em cima da cobra, invocamos recursivamente a função para obter uma nova posição.

Vamos dar um gás e adicionar mais uma feature: quando a cobra consumir a comida ela deve crescer.

Fazendo a serpente crescer após consumir a comida

Precisamos pensar: onde vamos mexer para adicionar esse comportamento? Já temos uma função que manipula exclusivamente a serpente, ela se chama movimentaCobra. É o melhor lugar para adicionar esse novo comportamento. Como vamos modificar a função para não somente movimentar a cobra, é uma boa ideia renomear a função. Um bom nome é atualizaCobra.

Vamos lá, renomear a função e adicionar novo comportamento:

1
2
3
4
5
6
7
8
atualizaCobra :: [Pos] -> Direcao -> Pos -> [Pos]
atualizaCobra cob Parado _ = cob
atualizaCobra cob d com    
    | com == novaPosicao = novaPosicao : cob --adicionado
    | otherwise = novaPosicao : init cob
    where
        novaCabeca = head cob +> d
        novaPosicao = reposicionaCabeca novaCabeca

A função agora precisa receber a posição da comida para poder checar se a serpente se movimentará para ela. Fazemos o movimento da cobra como antes, mas agora checamos se a novaPosicao está sobre a posição da comida (linha 4). Em caso positivo, adicionamos um novo segmento à serpente, caso contrário, fazemos a cobra se movimentar como já fazíamos antes. Como renomeamos a função para atualizaCobra, a função atualizaMundo também precisa ser modificada para usar a função com seu novo nome e informar o novo argumento (posição da comida):

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

Resumo

Agora o estado do jogo é o seguinte:

  • a cobra se movimenta corretamente, como esperado de acordo com a mecânica clássica desse jogo;
  • a comida é gerada aleatoriamente;
  • ao passar por cima da comida a cobra a consome;
  • consumir a comida faz com que uma nova comida seja criada em uma nova posição;
  • ao consumir a comida, a serpente aumenta de tamanho.

Progredimos bastante. Eu iria adicionar aqui a checagem de colisão para detectar se a serpente passou por cima dela mesma, causando o fim do jogo. Mas eu acho que tanto eu quanto você estamos cansados, vamos deixar isso para o próximo post. Até lá.

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

Updated: