[12] Construindo um jogo Snake usando a biblioteca Gloss (parte 1)

16 minute read

Esta mini-série de posts vai trabalhar com a biblioteca Gloss.

Esta biblioteca é capaz de desenhar gráficos vetoriais 2D. Sem muito esforço e com código muito simples, é possível renderizar coisas interessantes. É possível até desenvolver jogos simples.

Como estudo de caso, implementaremos um simples jogo Snake, no qual o jogador controla uma serpente em um plano. O objetivo do jogo é consumir comida que aparece aleatoriamente no terreno, o que faz a cobra crescer, tornando o jogo mais difícil aos poucos. É possível também “viajar” de um canto a outro do terreno. Saindo pelo lado esquerdo, a cobra reaparece do lado direito. O inverso também é verdade, e também para os limites superior e inferior. Algumas variações do jogo incluem diferentes níveis com obstáculos, por exemplo, mas nos concentraremos aqui no núcleo do jogo. Caso deseje, faça um fork do projeto no github e implemente suas variações :)

Princípios do Gloss

Como dito anteriormente, Gloss é uma biblioteca capaz de desenhar gráficos vetoriais em Haskell. Ao abrir a janela de um programa que usa a Gloss, é possível interagir com ele com as teclas de seta para movimentar a área de visualização, aplicar zoom com as teclas de “page up” e “page down”, rotacionar a visualização com teclas “home” e “end”, e resetar usando a tecla “R”.

Veja o programa abaixo para ter o primeiro contato com a biblioteca:

1
2
3
import Graphics.Gloss

main = display (InWindow "Hello World" (200, 200) (10, 10)) white (Circle 80)

Utilizamos a função display. Veja o tipo desta função:

1
2
3
4
data Display = FullScreen | InWindow String (Int, Int) (Int, Int)

display :: Display -> Color -> Picture -> IO ()

A função display recebe como primeiro argumento um valor do tipo Display. Este valor determina se o conteúdo será desenhado em uma janela (InWindow) ou como tela cheia (FullScreen). Caso deseje utilizar uma janela, você deve construir o valor passando alguns argumentos. No exemplo, o valor “Hello World” é o título da janela, (200, 200) é o tamanho da janela em pixels (largura x altura), e posição onde a janela deve aparecer na sua tela ((10, 10) no nosso exemplo).

O segundo parâmetro da função display é a cor de fundo da janela, no nosso caso white (branco). O último parâmetro, é o que deve ser desenhado. No exemplo, um círculo de raio 80 pixels. Simples não é? Esse é um exemplo mínimo, mas a partir daqui vamos desenvolver mais um pouco o nosso programa.

A função display, entretanto não faz tanta coisa interessante. Ela simplesmente desenha um valor do tipo Picture na tela (Circle 80 no exemplo anterior). Além da função display, temos as funções animate, simulate e play. A função play é a mais legal porque é a que vamos usar. Você pode ler um pouco mais sobre elas na documentação.

Para utilizar o Gloss no seu programa, é bastante simples. Se estiver usando a Stack (recomendo bastante que use a Stack), você só precisará mexer em dois arquivos de configuração. Abra o arquivo package.yaml e adicione a dependência gloss à seção “dependencies”. Caso tenha dificuldades em conseguir compilar o projeto por algum problema de dependência entre bibliotecas, altere o “resolver” no arquivo stack.yaml para a versão LTS mais recente (versão 18.18 na momento da escrita deste post). Você consegue isso usando o valor lts-18.18 no lugar do resolver que já vem configurado pelo Stack.

A função play

A função play tem muito mais parâmetros, pois ela é capaz de fazer muito mais coisa. Vejamos:

1
play :: Display -> Color -> Int -> world -> (world -> Picture) -> (Event -> world -> world) -> (Float -> world -> world) -> IO ()

Os dois primeiros parâmetros são usados exatamente da mesma forma que na função display. O terceiro parâmetros, do tipo Int é o número de passos de simulação para cada segundo de tempo, isto é, quantidade de frames por segundo, ou fps, do seu programa. O quarto parâmetro é um world, ou um mundo, que representa o estado do seu programa. Veja que é usado um tipo parametrizado, isto é, é um tipo que você vai criar no seu programa. E o tipo que for usado aqui também tem que ser usado nos demais parâmetros da função play.

Os demais parâmetros são funções. Em ordem, uma função responsável por transformar um mundo em uma Picture que possa ser desenhada na tela, ou seja, vai desenhar o estado atual do seu programa. A próxima, é uma função que será invocada sempre que um evento foi detectado. Exemplos de eventos: um clique de mouse, tecla pressionada. A última função é a que será executada em cada frame.

Essas duas últimas funções são particularmente interessantes, e podem ser bem complexas, porque uma delas vai ser responsável por tratar eventos do usuário, que podem potencialmente alterar o estado da aplicação. Veja inclusive que ela retorna um valor do tipo world, ou seja, espera-se que um evento tenha um efeito sobre o mundo. A segunda, representa a passagem do tempo na sua simulação. O primeiro parâmetro dela, inclusive, é um Float e representa o tempo passado entre a execução anterior da função e a chamada atual. Ela também retorna um mundo, pois também se espera que a passagem do tempo tenha um efeito sobre o mundo.

Uma coisa interessante e que talvez tenha passado despercebido por você é o fato de que o gerenciamento do estado atual (o valor do tipo world), é gerenciado automaticamente para nós. Não precisamos ficar passando esse valor de um canto para outro. Se um evento for disparado e resultar em uma alteração no mundo, simplesmente retorne esse novo mundo, e ele será passado quando necessário para as funções que convertem o mundo em uma Picture e também para a função que contabiliza a passagem do tempo. Na próxima chamada da função que desenha o mundo, esta também terá acesso ao mundo alterado pela função que trata os eventos. Ele fica sincronizado entre tudo isso. Podemos então focar apenas na lógica do nosso programa.

Main.hs

No arquivo Main.hs vamos colocar apenas a função main, que utiliza a função play:

1
2
3
4
5
main :: IO ()
main = do
    play (InWindow "hSnake" (tamJanela , tamJanela) (500, 500)) white 60 mundoInicial desenhaMundo tratarEvento atualizaMundo

Mais adiante implementaremos as funções mundoInicial, desenhaMundo, tratarEvento e atualizaMundo.

O conceito do jogo e tipos básicos

Vamos agora para algumas coisas importantes e conceituais do nosso jogo. A cobra do Snake é composta por uma série de segmentos. A maneira mais fácil de representar esse segmento é com um quadrado. A comida também pode ser representada visualmente como um quadrado, que geralmente é pintado de uma cor diferente, para ficar mais distinta do corpo da cobra.

Nossa cobra deve se mover livremente, mas apenas até certo ponto. Ela se move em um grid de células, que devem ter as mesmas dimensões dos segmentos da serpente. Usar as mesmas dimensões facilita um pouco quando queremos move-la pelo terreno. Assim, devemos decidir previamente o tamanho do segmento, e escolher o tamanho da tela em pixels como um múltiplo deste tamanho. Você pode experimentar depois com esses valores e chegar em proporções diferentes, afinal de contas, seu jogo é seu mundo, e você o que você quiser com ele. Mas aqui estamos no meu jogo kkkk.

Vamos começar criando então alguns tipos para algumas coisas que vamos precisar no jogo, no arquivo Lib.hs:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
type Pos = (Int, Int)

-- o valor Bool sinaliza se aquele segmento engoliu a comida e vai aparecer estufado
type Pos = (Int, Int)

data Direcao = Norte | Sul | Leste | Oeste | Parado deriving (Eq, Show)

data Modo = Inicio | Jogando | GameOver deriving (Eq, Show)

data Mundo = Estado {
    cobra :: Pos
  , direcao :: Direcao
  , contFrames :: Int
} deriving Show

O tipos Pos representa uma posição, e para nós é declarado apenas como um sinônimo para um par de inteiros, onde o primeiro inteiro representa a posição X (eixo horizontal) e o segundo a posição Y (eixo vertical). A seguir, declaramos o tipo Segmento, que vai representar o segmento da nossa cobra. Aqui ele é um par de Pos e Bool. O valor Bool do segmento vai sinalizar para a gente apenas se aquele segmento está “estufado” ou não. É só pra quando a cobra comer a comida, parecer que a comida está passando pelo corpo dela. Sei lá, deixa o jogo mais divertido :)

Depois disso declaramos o tipo Direcao. Você pode ver que os construtores tem nomes bem sugestivos, representando a direção para onde a cobra deve se mover. Lembre que a cobra se move sozinha após um comando de mudança de direção, então precisamos guardar essa informação. Em seguida, temos o tipo Modo, que representa o modo atual de jogo. Inicio significa que o jogo apenas começo e nada está se movendo. Outro modo é o Jogando, em que tudo está movendo como deveria, o jogo está em funcionamento. O terceiro modo é o GameOver, e o jogo deve transicionar para ele quando a cobra passar por cima do próprio corpo.

Por último, temos o tipo Mundo. Declaramos esse tipo como um “record”, pois queremos ter fácil acesso aos valores pelo seu nome. Um valor deste tipo representa o estado do nosso programa, então adicionamos um valor para cada coisa que queremos manter registro. O valor contFrames utilizaremos para contabilizar a quantidade de frames que passaram. Isso é útil para determinarmos ações para acontecerem apenas após um determinado número de frames. Ao invocar a função play informaremos que desejamos uma taxa de atualização de 60 fps, e após alguns testes, verifiquei que o jogo fica com uma velocidade legal se movermos tudo a cada 10 frames.

IMPORTANTE: Diferente de outros frameworks gráficos, o Gloss utiliza o centro da tela como a posição (0, 0). Nossa célula (0, 0) vai ser então no centro da tela. A posição no eixo horizontal aumenta para a direita e diminui para a esquerda. A posição no eixo vertical aumenta para cima e diminui para baixo.

Alguns valores importantes

Precisamos também definir um valor inicial para o mundo, isto é, o estado em que nosso jogo inicia e usado na função main lá no arquivo Main.hs. Veja abaixo:

1
2
3
4
5
6
mundoInicial :: Mundo
mundoInicial = Estado {
    cobra = (0,0)
  , direcao = Parado
  , contFrames = 0
}

Inicialmente nossa cobra não é realmente uma cobra, apenas um segmento. Vamos começar assim para ficar mais fácil de implementar as versões iniciais das funções que tratam eventos, desenham e atualizam o mundo.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
linhas :: Num a => a
linhas = 39

limite :: (Num a, Integral a) => a
limite = linhas `div` 2

tamJanela :: Num a => a
tamJanela = tamSegmt * linhas

tamSegmt :: Num a => a
tamSegmt = 10

acaoFrames :: Num a => a
acaoFrames = 10

Nosso terreno terá então 39 linhas tanto na largura quanto na altura. O limite de tela é calculado de acordo com a quantidade de linhas, bem como o tamanho em pixels da janela, pois queremos um múltiplo do tamanho do segmento. No nosso caso o tamanho do segmento será de 10 pixels. A seguir declaramos acaoFrames, que utilizaremos para decidir quando o jogo fará alguma ação.

Tratando eventos e atualizando o mundo

Precisamos implementar as funções desenhaMundo, tratarEvento, e atualizaMundo, que a função play precisa. Vejamos inicialmente desenhaMundo.

desenhaMundo

O conceito desta função é bem interessante, pois ela recebe um mundo e devolve uma Picture. Veja seu tipo: desenhaMundo :: Mundo -> Picture. Em outras palavras, ela é responsável por representar visualmente o estado do programa.

Neste momento só queremos mostrar algo na tela, então desenharemos um quadrado representando a serpente de apenas um segmento. Evoluiremos nosso mundo para comportar o restante do conceito do jogo, mas vamos devagar.

1
2
3
4
5
6
desenhaSegmento :: Pos -> Picture
desenhaSegmento (x,y) = translate (fromIntegral x * tamSegmt) (fromIntegral y * tamSegmt) $ rectangleSolid tamSegmt tamSegmt


desenhaMundo :: Mundo -> Picture
desenhaMundo m = desenhaSegmento $ cobra m

Iniciamos pela função desenhaSegmento. Desejamos desenhar um retângulo sólido, então invocaremos primeiro a função rectangleSolid. Leia mais sobre ela na documentação. Ela recebe dois valores Float, representando a largura e a altura do retângulo a ser desenhado, e retorna uma Picture. Na biblioteca, este tipo Picture é um dos mais importantes, pois ele representa as figuras que serão desenhadas na tela. Desenhar um retângulo assim, fará com que ele apareça na posição (0,0). Transformamos este retângulo usando a função translate, que move uma Picture para a posição desejada. Calculamos então para onde queremos mover o retângulo baseado nos valores de x e y do segmento da serpente e o tamanho do segmento. Isso vai posicionar o segmento no nosso grid imaginário de 39 linhas, onde cada célula tem exatamente a largura e a altura tamSegmt. Precisamos converter o valor de x e y para Float, caso contrário teremos um problema de tipos, pois a função translate espera dois Float como argumentos.

A função desenhaMundo apenas delega o desenho de todo o mundo para a função desenhaSegmento e passa para ela a cobra. É importante pensarmos nessa separação de responsabilidades, pois no futuro vamos querer também desenhar o alimento da cobra, um placar, contador de tempo, etc. e ficará bem mais fácil se separarmos as funções que desenham coisas diferentes.

tratarEvento

Agora vamos para tratarEvento:

1
2
3
4
5
6
tratarEvento :: Event -> Mundo -> Mundo
tratarEvento (EventKey (SpecialKey KeyUp) Down _ _) est@(Estado (x,y) _ _) = est {cobra = (x,y + 1)}
tratarEvento (EventKey (SpecialKey KeyDown) Down _ _) est@(Estado (x,y) _ _) = est {cobra = (x,y - 1)}
tratarEvento (EventKey (SpecialKey KeyLeft) Down _ _) est@(Estado (x,y) _ _) = est {cobra = (x - 1,y)}
tratarEvento (EventKey (SpecialKey KeyRight) Down _ _) est@(Estado (x,y) _ _) = est {cobra = (x + 1,y)}
tratarEvento _ est = est

O Gloss disponibiliza para nós o tipo Event. Com ele é possível saber exatamente qual evento foi disparado. Construimos nossa função tratarEvento para casar padrão com o que queremos. Em outras palavras, cada linha da nossa função será invocada apenas quando aquele evento específico tiver ocorrido.

Tomemos como exemplo a linha 1, onde queremos receber um evento do tipo EventKey, representando uma tecla, e informamos também que não é qualquer tecla, mas uma SpecialKey, e ainda dizemos que é a tecla KeyUp, que significa a seta para cima no teclado. Depois, dizemos que estamos interessados especificamente no KeyState Down. Desta forma, só seremos notificados quando a tecla for pressionada, mas não quando ela for liberada. Não nos importamos com os demais parâmetros, então usamos _ nos dois. Casamos padrão também com nosso estado, e desempacotamos a posição do segmento da cobra em x e y. Usamos uma construção nova: est@ antes do padrão do estado. Utilizando isso podemos referenciar tanto o valor completo do estado através do nome est e também de suas partes. Nesse caso, apenas dos valores x e y da posição. Retornamos um novo estado, com a posição da cobra atualizada, subindo uma célula verticalmente. As demais linhas são análogas, com exceção da última, que será responsável por receber qualquer outro tipo de evento. Nesse caso, desejamos que o estado se mantenha o mesmo.

atualizaMundo

No momento não vamos mover a cobra automaticamente, apenas quando dermos o comando no teclado, então a função pode ser muito simples:

1
2
atualizaMundo :: Float -> Mundo -> Mundo
atualizaMundo _ est = est

Traduzindo, a função é invocada, mas retorna o mesmo mundo, sem modifica-lo.

Resultado deste post

O resultado deste post pode ser encontrado no Github: https://github.com/emanoelbarreiros/hsnake/tree/parte1

Updated: