Criando o Jogo da Cobrinha em F# (ou será JavaScript?)
Olá pessoa!
Como post de volta férias resolvi trazer algo um pouco diferente. Que tal usar F# no navegador?
Ok, você pode ter achado que leu alguma coisa errada, mas é isso mesmo. Vamos executar F# no navegador.
Tá, eu confesso que na verdade não é bem assim. Vamos rodar JavaScript no navegador, mas usar uma ferramenta que nos permite escrever o código em F# e ele é transpilado para JavaScript.
Parece loucura? -Parece, mas vamos dar uma olhada.
Fable
A ferramenta que vamos utilizar para fazer esse trabalho se chama Fable. Ele é um compilador que permite utilizar F# no ecossistema JavaScript.
Vale um disclaimer aqui, eu não estudei o Fable super a fundo, então posso cometer algum deslize por aqui. Essa é oficialmente a primeira prova de conceito que fiz, como achei o resultado legal, resolvi compartilhar por aqui.
Para fazer o Fable funcionar precisamos de alguns pré requisitos:
- .NET Core SDK (para a parte de F#);
- Node.js para executar o código JS;
- Um gerenciador de pacotes JavaScript (eu utilizei o
npm
); - Ferramenta para desenvolvimento (eu fiz tudo pelo VS Code mesmo).
Como começar?
Eu segui a dica do site oficial do Fable e criei meu projeto baseado nesse exemplo do GitHub.
Esse exemplo é um dos mais simples disponíveis, ele simplesmente levanta a aplicação com um mensagem. Além disso, boa parte das configurações já estão pré definidas de uma maneira utilizável para o nosso cenário.
Vamos lá! Vamos entender essa maluquice de projeto.
A primeira coisa a notar é que temos duas pastas bem importantes aqui: public
e src
.
- A pasta
public
é onde ficarão nossos recursos estáticos, nesse exemplo, apenas HTML e o arquivo de ícone; - A pasta
src
é onde fica o nosso código F# que será compilado para JavaScript.
Nesse caso utilizaremos apenas o arquivo App.fs
para fins de simplificação de configurações, mas é totalmente plausível utilizarmos mais de um arquivo. Na verdade, existem alguns projetos de exemplo no Fable que são bastante sofisticados, incluindo componentes React e outras coisas de nível bastante impressionante.
Antes de conseguirmos executar o projeto precisamos configurar os pacotes (das duas linguagens).
Primeiro vamos executar um comando para instalar os pacotes npm, na raiz do projeto execute:
npm install
Depois de esperamos 328 horas até todos os pacotes do node_modules instalar precisamos instalar os pacotes .NET. Então navegue até o diretório src
onde encontra-se o arquivo “App.fsproj” e execute:
dotnet restore
Yay, já podemos executar o projeto, basta executarmos o comando e a magia acontece:
npm start
Apesar de funcionar, esse definitivamente não é um projeto legal o suficiente.
Estudo de Caso - Jogo da Cobrinha do Nokia
Vamos fazer um exemplo sem precisarmos interagir com o HTML da página, focando apenas no F# e ver como isso executa.
Caso você não faça a menor ideia do jogo que eu estou falando, apesar de eu achar isso estranho, o jogo é esse aqui:
Você controla uma cobra dentro do ambiente do jogo, o desafio do jogo é guiar o personagem até as maçãs (que aparecem em locais aleatórios) e acumular pontos. Cada vez que você come uma maçã a Snake aumenta seu tamanho, dificultando cada vez a locomoção.
Existem mais de uma versão desse jogo, mas em resumão é isso. Geralmente o que pode variar é:
- Ter outros tipos de alimentos;
- Poder bater nas paredes ou atravessá-las;
No nosso exemplo, vamos ter apenas a maçã como tipo de alimento e será permitido atravessar as paredes, ou seja, se você alcançar um canto da tela, irá sair do outro lado (como o Pac Man faz).
O legal disso é que temos um ambiente controlado e com regras relativamente simples.
Vamos começar!
Modelando os tipos
Como já foi dito anteriormente, as regras do jogo são bastante simple, então não devemos ter muitos problemas em modelar o domínio.
Precisamos de alguns tipos para garantir que o jogo funcione corretamente, vamos começar com um tipo para identificar a direção em que a Snake está se movendo.
Para isso, vamos usar um Discriminated Union:
type Direction =
| Right
| Left
| Up
| Down
Agora vamos criar um Record para identificar a posição (X e Y) de cada elemento do jogo dentro da tela:
type Position = {
X : int
Y : int
}
Até aqui, tudo foi bastante intuitivo. O próximo tipo será um Record para definir a nossa Snake. Neste caso precisamos pensar um pouco mais sobre o que vamos precisar.
A primeira coisa é a direção que a Snake está indo, isso deve ser um label
do tipo Direction
, criado anteriormente. Também precisaremos o comprimento da Snake, que deve ser incrementado cada vez que o jogador comer uma maçã.
Por fim, precisamos da posição da Snake. Aqui as coisas ficam um pouquinho mais complicadas, afinal, não temos apenas uma posição, temos o corpo inteiro da Snake. Então precisaremos de uma lista de posições!
type Snake = {
Direction : Direction
Length : int
Trail : Position list
}
Agora precisamos de um Record para representar o jogo propriamente dito. Ele precisará conter:
- A Snake;
- A maçã;
- A pontuação do jogador;
- O tamanho da tela.
Não é totalmente necessário armazenarmos o tamanho da tela, mas pode ser interessante para customizações:
type Game = {
Snake : Snake
Apple : Position
GridSize : int
Score : int
}
Para finalizarmos o domínio só precisamos de mais um tipo. Este último será utilizado para representar o estado produzido pela atualização do jogo. Ou seja, cada vez que atualizarmos a posição da Snake podemos gerar um novo resultado:
- O jogador continua vivo;
- O jogador marcou um ponto;
- O jogador colidiu e o jogo deve ser encerrado.
Podemos representar isso com o código abaixo:
type GameState =
| Alive of Snake
| Score of Snake
| Dead
Ainda no nosso modelo vamos definir algumas configurações por padrão:
let defaultHead = { X = 10 ; Y = 10 }
let defaultGridSize = 20
Com isso temos o tamanho padrão da tela e a posição inicial do jogador. Agora já podemos partir para as funções que realizam os comportamentos do jogo.
Implementando os comportamentos
A primeira função é talvez também a mais simples de todas, vamos implementar uma forma de acessar rapidamente a cabeça da Snake, que sempre será a última posição do label Trail
. Conforme código a seguir:
let getHead snake =
snake.Trail.[snake.Trail.Length - 1]
A próxima função é para sortearmos a posição de maçã, novamente bastante simples. Basta utilizarmos o objeto Random
para sortearmos um valor entre 0 e o tamanho máximo da tela:
let getApple() =
let randomizer = Random()
{
X = randomizer.Next(0, defaultGridSize)
Y = randomizer.Next(0, defaultGridSize)
}
Outra coisa que precisaremos com certeza é detectar se há colisão entre duas posições distintas, vamos fazer uma função para isso, simplesmente comparando se as coordenadas X e Y de duas posições distintas são iguais:
let checkColisionBetween positionA positionB =
positionA.X = positionB.X
&& positionA.Y = positionB.Y
Feito!
Nesse ponto as coisas complicam um pouco, mas não vai ser nada demais. Vamos implementar a função que checa a posição para qual a Snake deve se mover e ajusta as posições para “atravessar a parede” conforme imagem abaixo:
Para fazer esse comportamento, vamos receber as posições X e Y destinos e corrigí-las caso necessário, um pattern matching é capaz de resolver isso, conforme código:
let checkOutOfBounds newX newY =
match (newX, newY) with
| (x,y) when x < 0 -> defaultGridSize-1, y
| (x,y) when y < 0 -> x, defaultGridSize-1
| (x,y) when x > defaultGridSize-1 -> 0, y
| (x,y) when y > defaultGridSize-1 -> x, 0
| (x,y) -> (x,y)
Agora vamos implementar o método que identifica essa posição destino da Snake, para isso precisaremos checar a direção atual da Snake.
Isso indica se devemos incrementar ou decrementar a posição da cabeça da Snake. Depois disso, não podemos nos esquecer de corrigir a posição para atravessar a parede quando necessário.
let getNextPosition snake =
let (changeX, changeY) =
match snake.Direction with
| Direction.Right -> (1, 0)
| Direction.Left -> (-1, 0)
| Direction.Up -> (0, -1)
| Direction.Down -> (0, 1)
let head =
getHead snake
let (newX, newY) =
checkOutOfBounds (head.X + changeX) (head.Y + changeY)
{ X = newX ; Y = newY }
Como já sabemos a próxima posição da Snake está na hora de fazermos o método que a move de fato. Este método recebe por parâmetro a Snake e a posição para qual ela deve se mover. Além disso, não podemos esquecer de elimiar os elementos da lista que representa o corpo Snake que extrapolam o tamanho máximo.
Por exemplo, vamos imaginar que o tamanho máximo da Snake é 5, não podemos em nenhuma circustância ter mais elementos que isso na lista. Para evitar isso, basta removermos os elementos mais antigos.
Então os passos para realizar a movimentação são:
- Adicionar a posição destino como uma posição do corpo da Snake;
- Remover os elementos extras;
- Retornar uma Snake com a lista transformada.
let move snake toPosition =
let skipSize =
Math.Max(0, snake.Trail.Length + 1 - snake.Length)
{ snake with
Trail = snake.Trail @ [toPosition]
|> List.skip skipSize
}
Acredito que a próxima função seja a função mais complexa do jogo inteiro, mas calma, é só um jogo de Snake, então ainda é simples.
Vamos fazer a função que detecta as possíveis colisões e retorna um dos possíveis GameStates
do jogo. Essa função recebe por parâmetro a maçã e a Snake:
let checkColisions apple snake =
//...
A primeira coisa que vamos fazer é comparar se o jogador marcou um ponto, ou seja, se a cabeça da Snake está colidindo com a maçã.
Caso esteja, já podemos retornar o estado atual do jogo como Score
. Caso contrário, teremos que verificar se há alguma colisão entre a cabeça da Snake e seu próprio corpo (Caso você não queira fazer com que a Snake atravesse paredes, também seria aqui que a validação das paredes seria incluída).
let checkColisions apple snake =
let checkBodyColision head trailPositions =
//...
let head = getHead snake
if checkColisionBetween head apple
then Score snake
else checkBodyColision head snake.Trail
A função interna checkBodyColision
deve checar cada uma das posições da lista da Snake. Como você já deve saber, no paradigma funcional normalmente percorremos coleções através de uma função recursiva e não será diferente aqui.
A função recursiva consiste basicamente de um pattern matching que separa o primeiro elemento do resto da lista, fazendo a comparação um a um.
Para este caso, teremos 3 casos bases e um caso onde iremos decompor o problema e realizar a função recursiva propriamente dita.
- Caso a lista esteja vazia
-> Alive
; - Caso só reste a cabeça da snake na lista
-> Alive
; - Caso ainda estejamos percorrendo a lista e as posições do corpo e da cabeça estão colidindo
-> Dead
; - Caso ainda estejamos percorrendo a lista e as posições não colidem
-> chamada recursiva
.
Conforme código:
let checkColisions apple snake =
let rec checkBodyColision head trailPositions =
match trailPositions with
| [] -> Alive snake
| current :: [] -> Alive snake
| current :: tail when (checkColisionBetween head current) -> Dead
| current :: tail -> checkBodyColision head tail
let head = getHead snake
if checkColisionBetween head apple
then Score snake
else checkBodyColision head snake.Trail
Agora vamos implementar duas funções diretamente relacionadas com a atualização do jogo ao decorrer um tempo, uma delas será utilizada para alterar a direção da Snake quando o jogo continua executando:
let continueGame game snake direction =
{game with Snake = {snake with Direction = direction}}
E a outra deve executar as atualizações quando o jogador marcar um ponto. Isso inclui também atualizar a direção da Snake, mas além disso, a posição da maçã precisa ser sorteada novamente, a pontuação deve ser aumentada e o tamanho da Snake incrementado:
let score game snake direction =
{game with
Snake = {snake with Direction = direction ; Length = snake.Length + 1}
Score = game.Score + 1
Apple = getApple()
}
Por fim, teremos a última função de comportamento do jogo, que basicamente consiste em chamar as funções principais já criadas.
Esta função deve receber um Game
e retornar um GameState
, conforme código:
let run game =
game.Snake
|> getNextPosition
|> move game.Snake
|> checkColisions game.Apple
Todos os comportamentos necessários para fazer o jogo executar corretamente já estão criados, mas ainda precisamos da interface!
Implementando a interface
A primeira parte do código é simplesmente estabelecer a conexão com o navegador através dos objetos fornecidos nos módulos do Fable.
Com eles podemos acessar uma representação do DOM e acessar o elemento de maneira bastante similar ao que fazemos com JavaScript, veja:
open Fable.Core.JsInterop
open Fable.Import
open Browser.Types
open SnakeGame
let window = Browser.Dom.window
let document = Browser.Dom.document
let mutable myCanvas : Browser.Types.HTMLCanvasElement =
unbox window.document.getElementById "myCanvas"
Depois disso, vamos inicializar alguns valores e variáveis para definirmos parâmetros de interface para o jogo e para interagirmos com o contexto de nosso canvas HTML:
let context = myCanvas.getContext_2d()
let defaultTileSize = myCanvas.width / (defaultGridSize |> float)
let mutable direction = Direction.Right
let defaultGameSettings = {
Apple = getApple()
Score = 0
GridSize = defaultGridSize
Snake = {
Trail = [ defaultHead ]
Direction = Direction.Right
Length = 5
}
}
O valor context
é simplesmente utilizado para interagir com o canvas, assim como fazemos em JavaScript. Os valores defaultTileSize
e defaultGameSettings
serão utilizados para inicializar o jogo e imprimir a interface corretamente.
A variável direction
é a varíavel que será alterada pelo jogador nos eventos de teclado, isso influenciará na direção da Snake dentro do jogo.
Falando nisso, vamos implementar a função que realiza a captura do evento de teclado e altera esse valor.
Vale lembrar que o objeto de evento utilizado aqui, possuirá a propriedade keyCode
assim como no JavaScript (inclusive com os mesmos códigos), veja:
let isValidChange fromDirection toDirection =
fromDirection = Direction.Right && toDirection <> Direction.Left
|| fromDirection = Direction.Left && toDirection <> Direction.Right
|| fromDirection = Direction.Up && toDirection <> Direction.Down
|| fromDirection = Direction.Down && toDirection <> Direction.Up
let commandPressed (event:KeyboardEvent)=
let newDirection =
match event.keyCode with
| 37.0 -> Direction.Left
| 38.0 -> Direction.Up
| 39.0 -> Direction.Right
| 40.0 -> Direction.Down
| _ -> direction
if isValidChange direction newDirection
then direction <- newDirection
()
Note também que implementamos uma pequena função para validar se podemos realizar a troca de direção.
Agora vamos fazer a função que converte uma posição X, Y de nosso jogo em uma posição no X,Y Canvas. Precisamos fazer uma conversão porque o tamanho de cada ponto na tela pode alterar de acordo com o Canvas no HTML e com o nosso GridSize
definido no Record Game
.
Para isso basta multiplicarmos a posição (X ou Y) pelo tamanho padrão de cada tile que calculamos anteriormente:
let getCanvasPosition position =
position
|> float
|> (*) defaultTileSize
Estamos quase lá!
Faltam apenas 3 funções: desenhar o canvas, resetar o jogo quando o jogador perde e a função principal.
Vamos fazer a função para desenhar o canvas, ela deve receber o Game
e desenhar todos os elementos, se você já está acostumado com o Canvas, verá que é basicamente a mesma coisa com a sintaxe do F#.
Pintaremos o fundo de preto ("black"
) a Snake de verde claro ("lime"
) e a maçã de vermelho ("red"
), conforme código abaixo:
let printCanvas game =
context.fillStyle <- !^ "black"
context.fillRect (0., 0., myCanvas.width, myCanvas.height)
context.fillStyle <- !^ "lime"
for position in game.Snake.Trail do
context.fillRect ( position.X |> getCanvasPosition,
position.Y |> getCanvasPosition,
defaultTileSize - 2., defaultTileSize - 2.)
context.fillStyle <- !^ "red"
context.fillRect ( game.Apple.X |> getCanvasPosition,
game.Apple.Y |> getCanvasPosition,
defaultTileSize - 2., defaultTileSize - 2.)
Agora implementaremos a função de reiniciar o jogo, ela é bastante simples. Vamos apenas exibir um alert
com a pontuação do jogador, depois disso, reiniciaremos a direção e as configurações do jogo.
let resetGame score =
window.alert(sprintf "Score: %i" score)
direction <- Direction.Right
defaultGameSettings
Agora sim, a função principal do jogo! Essa é a função que deverá ser chamada a cada atualização de tela.
Basicamente o que esta função irá fazer é:
- Desenhar o canvas;
- Executar a lógica principal do jogo (função
run
); - Criar um novo Record com a versão atualizada do jogo baseada no resultado do passo anterior.
Para o terceiro passo, basta usarmos as funções já criadas anteriormente!
Vamos lá:
let rec snakeGame game =
printCanvas game
let state = run game
let updatedGame =
match state with
| Alive snake -> continueGame game snake direction
| Score snake -> score game snake direction
| Dead -> resetGame game.Score
Agora precisamos realizar a chamada recursiva, mas nesse caso, não vamos simplesmente chamar a função. Isso faria com que a atualização de tela fosse rápida demais ficando quase impossível jogar.
Vamos manter o FPS perto de 60, então faremos uma chamada recursiva em um intervalo de tempo de 1000/15:
let rec snakeGame game =
printCanvas game
let state = run game
let updatedGame =
match state with
| Alive snake -> continueGame game snake direction
| Score snake -> score game snake direction
| Dead -> resetGame game.Score
window.setTimeout( (fun args -> snakeGame updatedGame), 1000/15)
|> ignore
Por fim, vamos fazer o binding
do evento de teclado e iniciar o jogo:
document.addEventListener("keydown", fun event -> commandPressed(event :?> _))
snakeGame defaultGameSettings |> ignore
Você pode conferir o resultado do jogo abaixo ou neste link!
Atenção
Bom, o post de hoje era isso!
Espero que tenham gostado, qualquer dúvida, correção ou sugestão, deixem nos comentários!
E até mais.
Sempre vale lembrar que as informações e textos aqui no blog representam minha opinião pessoal, o que pode não ser igual à sua ou de qualquer outra pessoa, incluindo a empresa para qual eu trabalho. Portanto as publicações inseridas aqui estão relacionadas somente a mim.