Desenvolvimento de Jogos Online com Rust
Por Julia Naomi Boeira.
Escrever um livro open source é um trablho que precisa de incentivo e por isso Github Sponsor e Patreon são coisas importantes, pois além de atuarem como incetivo, são um bomr econhecimento do nosso trabalho. Escrevi bastantes livros pela casa do código, mas em especial no assunto Rust e Games eu sinto que falta alcance, e por isso gostaria de continuar produzindo esse tipo de material.
Sobre o livro
Até o momento planejei 3 partes para este livro:
- Conceitos Básicos, onde vamos falar sobre como funciona um jogo online e quais são suas limitações.
- Jogo da cobrinha com a engine Bevy, essa é a parte menos criativa do processo e é uma cópia traduzida deste tutorial Bevy Snake Tutorial. A diferença é que vou adicionar elementos de um jogo multiplayer local.
- Servidor autoritário com a Bevy.
ESPERO QUE APROVEITEM A LEITURA e feedbacks são bem vindos.
Quem sou eu
Eu sou uma desenolvedora de jogos na Ubisoft Winnipeg atuando no desenvolvimento de sistemas online, middlewares e ferramentas para jogos. Trabalho principalmente com C++, mas um pouco de C# e Rust. Sou autora dos livros:
- 📖 Lean Game Development - Inglês - Apress
- 📖 Lean Game Development - Português - Casa do Código
- 📖 Programação Funcional e Concorrente em Rust - Casa do Código
- 📖 TDD para Games - Casa do Código
- 📖 [OPEN SOURCE] Desenvolvimento Web com Rust
E atualmente estou desenvolvendo em paralelo a este livro o livro Unity FPS game with TDD - Inglês.
Tenho atuado como evangelista voluntaria de Rust desde 2017 quando me deparei com Rust pela primeira vez e percebi que esta maravilhosa linguagem era um raio de esperança nos problemas que eu tinha no desenvolvimento de jogos com C++.
Hobbies como engenheira são aprender novas linguagens, em especial de paradigmas diferentes ou que pelo menos possuem uma forma bem diferente de resolver problemas, tornando Clojure e Elixir minhas duas outras linguagens favoritas. E no meu tempo livre escrever e fazer prototipos bobos de jogos como esses (quando eu estava aprendendo Java) https://github.com/naomijub/DiammondSeek e https://github.com/naomijub/PacmanLabyrinth.
Curiosidade sobre aprender linguagens de programação, tentei aprender Java na faculdade, mas simplesmente não entrava na minha cabeça, foi graças a XNA e aos C# que consegui aprender Java e arrumar trabalho com software corporativo. Tentei aprender Go 3 vezes e NUNCA entra na minha cabeça. Trabalhei muito com Python a ponto de dizer que era uma das minhas linguagens favoritas, mas hoje em dia eu fujo de qualquer projeto Python.
Passei pelas faculdades de Matematica Aplicada, Engenharia de Materiais e Ciências da Computação. Larguei a CC porque já estava cursando mestrado em inteligência artificial aplicada a engenharia de materiais e depois aiinda fiz especialização em desenvolvimento de jogos para suprir as áreas que faltavam do meu conhecimento em jogos. A empresa que mais gostei de trabalhar é a Ubiisoft DE WINNIPEG, mas outras empresas que gostei muito foram a Thoughtworks até 2018, onde conheci pessoas incríveis que são minhas amigas até hoje, e Nubank que foi um lugar de muito aprendizado.
Para dúvidas sobre o livro, discussões sobre o tema e correções sugiro abrir issues ou criar Pull Requests.
Conceitos importantes para o desenvolvimento de serviços de jogos digitais
- O problema e sua arquitetura básica.
- Predição e reconciliação.
- Interpolação de entidades.
- Compensação de lag.
O problema e sua arquitetura básica.
Neste capítulo vamos entender quais os problemas que serviços para games enfrentam e quais são algumas das formas de resolvê-los para obtermos um conjunto de serviços que tornam o desenvolvimento de jogos multiplayer uma realidade.
Multiplayer
Jogos multiplayer são jogos com mais de uma pessoa jogando simultaneamente se conectando através de um servidor.
Introdução
Desenvolver um jogo é bastante complicado, agora desenvolver um jogo para mais de uma pessoa jogando é ainda mais complicado. Felizmente, podemos resumir os problemas que servidores de jogos possuem em duas categorias:
- Humanos maliciosos.
- Física realística.
Humanos Maliciosos
Tudo começa com o desejo das pessoas de trapacear em um jogo.
Podemos dizer que para jogos single-player, ou de somente uma pessoa jogando, trapacear afeta a experiência, mas é uma escolha da pessoa burlar a experiência do jogo, a final a trapaça não afeta ninguém além da pessoa, porém para jogos multiplayer o cenário é diferente. Em um jogo multiplayer, uma pessoa burlando as regras do jogo pode conseguir algumas vantagens que além de afetar sua experiência, tornam a experiência das outras pessoas muito pior. Alguns exemplos que já vi na minha vida:
- Vida muito maior que 100%, ou seja, a pessoa possuia 1000% de vida em uma partida, tornando ela quase imortal, já que colecionava muito mais recursos.
- Tiros duplos ou triplos, ou seja, para cada vez que a pessoa realizava um tiro, duas ou três balas eram enviadas ao mesmo tempo, reduzindo muito as chances do alvo de sobreviver.
- Atravessar paredes, não sei bem como este mod funcionava, mas acredito que projetava a pessoa para além do objeto de colisão.
- Paredes invisíveis, ou seja, a pessoa havia removido a renderização de objetos inanimados, o que a permitia visualizar todos os alvos antes de ser percebida.
- Velocidade 2, ou seja, para cada passo da pessoa, o jogo a movia 2 vezes mais rápido.
Tendo estes eventos em mente, podemos concluir que existe uma única solução realmente confiável para um servidor NÃO CONFIAR NO USUÁRIO.
Como não confiar no usuário?
A resposta para está pergunta é na verdade bastante simples, o cliente, ou seja a pessoa jogando, deve fornecer o mínimo de informações em relação ao seu posicionamento, balas disparadas, direção, etc. Enquanto isso, o servidor deve ser autoritário, recendendo estes comandos básicos e informado para o cliente o que está acontecendo. Em outras palavras, o cliente envia comandos e botões pressionados para o servidor, o servidor executa o próximo passo do jogo e devolve ao cliente as novas informações. Isso não vai impedir que o servidor seja explorado de vulnerabilidades, mas reduzirá drasticamente a capacidade de uma pessoa jogando de trapacear. Assim, para o caso da pessoa que está dando tiros múltiplos, ela pode até ver 3 tiros saindo de sua arma, mas o servidor reconhecerá somente 1 e propagará ao resto do jogo somente 1.
Resumindo, o gerenciamento do estado do jogo é realizado apenas pelo servidor. Clientes enviam apenas suas interações com o controle, teclado e mouse para o servidor. O servidor atualiza o estado do jogo e envia esta informação de volta aos clientes que apenas renderizam ela em sua tela.
O problema com a física
Parece uma solução perfeita né? Infelizmente ela funciona bem somente quando o jogo é baseado em turnos, como jogos de carta e alguns RPGs, ou a rede é em LAN, já que neste cenário a comunicação com o servidor é considerada instantânea. Para jogos como Call of Duty e Rainbow Six está estratégia vai contar com um enorme delay já que precisam se conectar com servidores distantes.
Assim, vamos supor o meu cenário. Mesmo que minha conexão à internet seja sensacional (mentira, isso não existe), estou em Porto Alegre e o servidor mais próximo está em São Paulo para o jogo X. Porto Alegre e São Paulo estão distantes entre si mais de 1100 km. Na física a velocidade da luz é a maior velocidade atingível por um corpo (photons no caso), ou seja 300.000 km/s no vácuo, assim a luz levaria 3,7 milisegundos para percorrer os 1100 km (1100/300000 = 0,0036667 segundos). Essa é a velocidade da luz no vácuo, parece bem otimista né? Mas neste caso estamos falando de bytes trafegando pela internet, que na prática são elétrons e pulsos de luz trafegando por um cabo, e provavelmente não em linha reta, o que deve aumentar esse valor de 3,7 por alguns microsegundos. Existe mais um fator importante em como a internet funciona, os dados trafegados pela internet são na verdade uma séries de pacotes, ou hops, que trafegam de um roteador ao outro, certamente abaixo da velocidade da luz. Além disso, roteadores possuem um atraso extra, já que todos os pacotes devem ser abertos, copiados e inspecionados para então serem reroteados a seus destinos finais.
Vamos então dizer que o atraso dos meus pacotes até São Paulo leva 25 ms, o que seria um tempo excepcional (neste momento um ping da minha máquina ao google.com está levando entre 25 e 30 ms), mas tempos de 50 ms e até 200 ms não seria impressionantes para certas situações. Agora vamos dizer que nossa jogadora apertou para atirar no momento x, isso quer dizer que nosso servidor receberá a ação de atirar 25 ms depois. Digamos que nosso servidor processe o evento em um tempo substancialmente menor que 1 milisegundo, algo como 500 us, isso quer dizer que quando o servidor responder, a jogadora receberá essa atualização 50 ms depois de ter clicado para atirar. Humanos em média enxergam 25 frames por segundo, o que indica que o delay já é maior que nossa capacidade de observação por 10 ms. Esses 10 ms de delay na nossa percepção já são suficiente para termos uma experiência ruim de jogabilidade, ou seja, o famoso lag, ou atraso. A imagem a seguir demonstra este efeito:
Predição e Reconciliação
No capítulo anterior falamos sobre o lag, ou atraso entre ação no cliente e a atualização enviada pelo servidor nos baseando no modelo de cliente servidor na qual o cliente não responde seu estado, mas sim a ação desejada, para que o servidor atualize seu estado. Um jogo que pode levar algumas frações de segundo para atualizar o estado pode ser considerado de jogabilidade ruim ou injogável devido ao lag de renderização. Assim, neste capítulo vamos explorar uma solução para minimizar este problema.
Predição pelo lado do cliente
Como a maior parte dos jogos é deterministico, ou seja, não há aleatoriedade no resultado, podemos prever qual vai ser o próximo passo do jogo antes do servidor responder. Para maior parte das pessoas jogando esta experiência será "idêntica" ao jogo sem servidor, mas para as pessoas trapaceando a experiência não será realistica, desfavorecendo o jogo com trapaças. Assim, podemos assumir que nosso servidor receberá ações válidas para 99% dos casos, nos permitindo prever o próximo instante.
No cenário que descrevemos anteriormente nossa ação com o servidor levava 50 ms para atualizar o estado do jogo, para só então uma animação ser ativada (digamos que ela leve mais 50 ms) como a imagem a seguir nos mostra:
Nessa imagem podemos ver que o atraso do servidor (50 ms) mais o tempo de animação (50 ms) fará com que percebemos o tiro apenas 100 ms depois dele ter sido realizado, ou seja, no terceiro frame que nosso olho detecta, certamente uma experiência desagradável.
Como o jogo nosso jogo é deterministico, podemos presumir que a ação será executada com sucesso no servidor, aplicar nossas regras locais de validação e iniciar a animação do tiro no momento em que pressionamos o botão para realizar a ação. Para a grande maioria dos casos a atualização do servidor e o final da animação vão coincidir em estado e fizemos um predição bem sucedida, fazendo com que não exista atrasos entre a ação e a renderização. Para os casos de trapaça a animação ocorrerá, mas em nada afetará o estado geral do jogo, somente afetará negativamente a experiência do usuário trapacendo.
Problemas de sincronização
Infelizmente essa estratégia não é perfeita e problemas de sincronização ou eventos conflitantes podem acontecer. Imagine agora o cenário na qual o personagem está se movimentando e o tempo de atraso é 75 ms em vez dos 50 ms anteriores, o tempo da animação é de 30 ms e a pessoa pressiona para se movimentar para frente 2 vezes seguidas. A imagem a seguir e os passos marcados na imagem exemplificam:
- Personagem está o ponto
(0,0)
no instante 0 ms. - Neste mesmo instante a pessoa pressiona para se movimentar enviando uma ação para o servidor que durará 75 ms.
- A ação do passo 1 ativou uma animação que moveu o personagem para a posição
(0,1)
30 ms depois. - Na posição
(0,1)
uma nova ação de movimentação acontece, enviando esta nova ação para o servidor que durará mais 75 ms. - A ação do passo 3 ativou uma nova animação que moveu o personagem para a posição
(0,2)
30 ms depois. Já se passaram 60 ms. - 15 ms depois de terminar a ação 4, o servidor respondeu a ação 1 fazendo o personagem voltar para posição
(0,1)
. Já se passaram 75 ms. - 30 ms depois de terminar a ação 5, o servidor respondeu a æção 3 fazendo o personagem voltar para posição
(0,2)
. Ja se passaram 105 ms.
Com este detalhamento podemos ver que pelo ponto de vista da pessoa jogando, o personagem vai responder as duas primeiras ações se movimentando até a posição (0,2)
para então voltar para posição (0,1)
e depois ainda voltar para posição (0,2)
gerando uma péssima experiência de jogo, forçando assim a adotarmos uma estratégia de reconciliação.
Reconciliacão pelo servidor
A chave deste problema é entender a diferença temporal dos cliente e do servidor, já que o cliente vê o jogo em tempo real (presente) e o servidor autoritário está no passado. Assim, sempre haverá uma diferença de sequência de comandos a serem processados entre o cliente e o servidor. Felizmente isso não é muito difícil de resolver.
Primeiro passo é fazer com que o cliente salve suas ações em uma sequência de comandos, assim a primeira movimentação seria a ação #1
e a segunda movimentação seria a ação #2
. Logo, o servidor poderá respoderá responder uma ação identificando a qual comando ela pertence. A figura a seguir exemplifica o que acontece:
- O evento
#1
é lancado, 30 ms depois da animação a posição#1 => (0,1)
é registrada e 38 ms depois o servidor recebe a ação#1
. A sequência de comandos é[#1 => (0,1)]
. - O evento
#2
é lancado, 30 ms depois da animação a posição#2 => (0,2)
é registrada e 38 ms depois o servidor recebe a ação#2
. A sequência de comandos é[#1 => (0,1), #2 => (0,2)]
. - O evento
#1
é retornado pelo servidor com o valor#1 => (0,1)
. A funçãocheck
para o estado da sequência de comandos atual ([#1 => (0,1), #2 => (0,2)]
) e o evento#1 => (0,1)
recebido é executado para reconciliar. Remove todos os comandos até#1 => (0,1)
da sequência de comandos. - O evento
#2
é retornado pelo servidor com o valor#2 => (0,2)
. A funçãocheck
para o estado da sequência de comandos atual ([#2 => (0,2)]
) e o evento#2 => (0,2)
recebido é executado para reconciliar. Remove todos os comandos até#2 => (0,2)
da sequência de comandos. - Sequência de comandos é
[]
.
Descrição da função
check
- Argumentos são sequência de comandos executados e evento #.
- Verifica se o valor de
#n
na sequência de comando é igual ao que o servidor retornou. Caso não for igual retorna erro.- Aplica o próximo evento,
#n+1
, ao resultado do evento#n
. Caso o resultado de#n
mais o evento#n+1
não corresponder ao evento salvo na sequência de comandos para#n+1
retornar erro. Observação: Se o evento que o servidor responder não for#n
esperado, podemos concluir que o pacote se perdeu ou o servidor retornou um erro, assim existem duas alternativas 1. descartar todos os pacotes até o evento recebido e fazer o check, ou 2. aplciar todos os eventos anteriores até o evento recebido. Particularmente vejo a soluação 1 sendo a mais comum, pois sabemos que o estado anterior está certo.
Este é um exemplo bem simples de movimentação e bastante intuitivo de visualizar, mas as aplicações de predição e reconciliação podem ser feitas em praticamente qualquer área do jogo e qualquer tipo de jogo. Imagine um jogo de corrida multiplayer e você está na linha de chegada em velocidade máxima, com um carro logo atrás de você. No próximo segundo considerando as atuais circunstâncias, é óbvio que você vai ganhar, pois você está na frente do outro carro e com uma velocidade maior, mas agora imagine que alguns milésimos antes do final da corrida a outra pessoa apertou o botão de nitro e te ultrapassou. A predição diria que seu carro ganharia a corrida, mas o servidor disse que não e você ficou em segundo lugar. Isso nos leva a um ponto interessante, mesmo em ambientes determinísticos, existe a chance da predição e da reconciliação não serem iguais, Para um cenário de fim de jogo como descrito aqui é bastante trivial a resposta, ignore a predição e responda com o resultado do servidor, porém se isso acontecer frequentemente no meio do jogo a experiência de jogabilidade vai ser ruim.
No próximo capítulo vamos explorar como resolver este problema de predição e reconciliação através de interpolação de entidades.
Interpolação de Entidades
Nos capítulos anteriores lidamos com o problema de uma pessoa poder trapacear e como fazer com que o jogo se mantenha conciliado com um servidor autoritário dando a sensação de que o servidor não existe, porém não expandimos este problema para quando estamos lidando com mais de uma pessoa jogando online. Neste capítulo vamos explorar técnicas que nos permitem manter a jogabilidade quando várias pessoas estão interagindo umas com as outras em um ambiente online.
Lidando com centenas de ações simultâneas
No capítulo anterior falamos sobre o servidor processar uma sequência de comandos e retornar como eventos autoritários para o cliente. Imagine agora que este cliente está alucinadamente mandando eventos para o servidor e que ele não está sozinho, pois existem mais uma dezena de clientes mandando eventos simultaneamente para o servidor. Sendo assim, atualizar o estado do jogo para cada comando recebido de cada cliente e depois transmitir o estado do jogo de volta para cada cliente consumiria muita CPU e muita banda.
Tendo em vista evitar o consumo desnecessário de CPU e banda outra abordagem parece fundamental. Esta nova abordagem consiste em enfileirar os comandos que os clientes enviam, sem processar eles, e em vez de atualizar o estado do jogo imediatamente para cada comando, fazemos atualizações periódicas e de baixa frequência, por exemplo 10 vezes por segundo. Este atraso entre cada update, no caso do nosso exemplo de 100 ms, é chamado de time step, ou passo temporal. O time step é definido como uma iteração de loop de update na qual todas as informações não processdas de todos clientes são aplicadas e o novo estado é transmitido para todos os clientes. Ou seja, o estado do jogo é atualizado com uma periodicidade específica de forma independente e não é afetado pela quantidade de clientes e seus comandos.
Obs: Muitas vezes a física do jogo é atualizada em passos de tempo menor para aumentar a previsibilidade.
Updates de baixa frequência
Seguindo com o conceito de um update de estado a cada 100 ms um novo problema aparece, os outros clientes não tem ideia de como seus oponentes estão se atualizando, gerando eventos que parecem bastante bruscos a cada atualização. Ou seja, predição e reconciliação funcionam muito bem para o lado do cliente, mas não para o resto das pessoas jogando. A imagem a seguir detalha melhor essa situação:
Na imagem anterior podemos ver o mesmo cenário de predição e reconciliação funcionando muito bem para o Cliente 1
, permitindo que sua jogabilidade seja coerente com a jogabilidade de um jogo single-player, porém para o Cliente 2
podemos ver que as transições (0,0) -> (0, 1)
e (0, 1) -> (0, 2)
do Cliente 1
são bruscas para o Cliente 2
, já que estas atualizações dependem exclusivamente das atualizações do servidor.
Agora voltando ao exemplo dos carros que mencionamos no final do capítulo anterior. Estamos em uma situação na qual temos controle do nosso carro, mas o carro da outra pessoa é determinado pelo servidor. Se este carro recebe atualizações apenas a cada 100 ms, teremos uma animação péssima de seu deslocamento, nos obrigando a encontrar outra solução para melhorar a experienência. Esta outra soluação envolve fazer a predição da posição do outro carro do lado do nosso cliente, pois sabemos sua direção, sua velocidade e temos certeza que o carro não fará um movimento radical, como girar 180 graus. Sendo assim, se o outro carro está indo reto com uma velocidade de 100 km/h, podemos prever que nos próximos 100 ms o carro estará 0,2 metros a frente de onde ele está neste exato segundo. Essa predição pode parecer maravilhosa, já que ele só se deslocou 0,2 metros em linha reta, mas infelizmente 100 ms é tempo suficiente para muitas outras coisas acontecerem como uma curva aparecer, bater em um poste, desacelerar ou até mesmo frear bruscamente. Chamamos está técnica de dead reckoning. Portanto, o dead reckoning é uma técnica de predição dos movimentos de outras pessoas em jogos na qual sua posição, velocidade e direção não são afetadas de forma instantânea, permitindo uma pequena margem para prever movimentos sem grandes danos à experiência. Caso alguma ação inesperada aconteça aceitamos que vamos conviver com uma cena estranha.
Dead reckoning é originalmente uma estratégia militar para prever a próxima localização de um navio, que se move lentamente e sem grandes oscilações de direção, para que se possa prever onde um torpedo precisa ser lançado para acertar o navio.
E para cenários muito dinâmicos?
Como falamos anteriormente, dead reckoning é bom para jogos que não são tão dinâmicos, como jogos de corrida, porém para jogos na qual as pessoas jogando se movimentam constantemente, atiram, se abaixam, pulam, giram 180 graus é impossível prever o próximo passo da pessoa apenas com dados anteriores. Se aplicássemos dead reckoning em um jogo de tiro veríamos personagens se teletransportando pequenas distâncias, múltiplas balas saindo de diferentes lugares e personagens fazendo movimentos impossíveis. Sendo assim, outra estratégia é necessária para jogos de tiro, sendo essa a interpolação de entidades.
No cenário descrito do parágrafo anterior, temos certeza apenas de 1 coisa, que a cada 100 ms temos uma atualização das informações do estado do jogo e dos personagens. Tendo em vista que sabemos o passado todo, o truque é mostrar para pessoa jogando o que acontece entre esses dados que já sabemos. Ou seja, a solução é mostrar para a pessoa que está jogando o passado relativo dos outros personagens. Isso que chamamos de interpolação de entidades.
Explicando melhor, podemos dizer que no momento t = n + 1
, que você acabou de receber, a posição do momento t = n
é conhecida. Sendo assim, neste momento t = n + 1
conhecemos as posições referentes a t = n
e t = n + 1
. Portanto, para o momento t = n + 2
mostramos o passado, ou seja, o que ocorreu no momento t = n = 1
e para o momento t = n + 1
mostramos o que ocorreu no momento t = n
do outro personagem. Deste modo o servidor está sempre mostrando as informações reais de movimentação dos outros personagens, porém com um "pequeno atraso" de 100 ms. A imagem a seguir exemplifica:
O diagrama de interpolação nos mostra bem como estamos prevendo os passos intermediários. Para um momento inicial estamos com a posição P(0,1)
, depois o servidor nos atualiza com a posição P(0,1)
novamente, neste momento exibimos a posição que conheciamos antes do step time, a V(0,1)
. Quando recebemos a posição P(0,2)
, mantemos a posição V(0,1)
, que havia sido entregue anteriormente pelo servidor. Agora sabemos o vetor de posições [P(0, 1), #1 P(0, 1), #2 P(0, 2)]
, e podemos interpolar que no próximo step time nosso personagem inimigo vai para a posição V(0,2)
passando pela posição V(0,1.75)
, melhorando a experiência da pessoa jogadora.
Na maior parte dos casos interpolação funciona muito bem, porém existem alguns casos que pode ser importante enviar mais informações de posições intermediárias entre #1
e #2
. Ou seja, se atualizações de estado a cada 100 ms não são suficientes, podemos enviar as últimas 10 atualizações que ocorreram com intervalos de 10 ms, que certamente vai fazer com que seu jogo pareça mais realista. Note que está técnica faz com que cada jogadora perceba pequenas variações do ambiente do jogo em relação às outras pessoas, que geralmente não é algo perceptível. Infelizmente, nada é perfeito e existem exceções como no caso de quando damos um tiro, pois estamos atirando na personagem da outra pessoa de 100 ms atrás. É nesse caso que precisamos explorar o último tópico desta parte, compensação de lag.
Compensacão de Lag
O cenário que temos até agora parece funcionar muito bem para percebermos movimentações, pois temos:
- Dado um tempo n, nosso servidor recebe informações de todos os clientes.
- Servidor processa todas as informações e transmite as atualizações.
- Estas atualizações são periódicas e de baixa frequência.
- Clientes enviam informações e verificam seus efeitos localmente.
- Clientes recebem as atualizações de estado do jogo:
- Reconciliam com os efeitos que previram.
- Interpolam os efeitos dos outros personagens.
- Cliente se vê no presente, mas vê os outros cliente no passado.
Esta situação é geralmente ótima, a menos quando precisamos garantir situações como um tiro na cabeça, que qualquer pequena variação pode causar um erro, pois as informações de tempo e espaço são muito sensíveis. É ai que entra a compensação de lag.
Imagine o cenário na qual você é uma sniper mirando perfeitamente na cabeça de um personagem "imóvel", um tiro dificil de errar. Você atira e, magicamente, nada acontece. Você se irrita, sai da partida e desliga o jogo pensando como pode ter errado aquele tiro perfeito e, pior, a pessoa que você devia ter matado te matou. Este é o efeito de lag temporal, pois seu tiro ocorreu em um personagem que estava 100 ms no passado, para quem gosta de física, é como se a velocidade da luz fosse muito muito muito inferior a que realmente é. Felizmente, existem algumas estratégias para resolver este efeito. Vamos detalhar como isso pode ser reolvido:
- Você deu um tiro, seu cliente enviou as informações para o servidor, mas desta vez enviou mais informações além do botão que você clicou, pois enviou o botão que você apertou, o exato momento temporal que você apertou o botão (e se o botão de mira estava sendo apertado) e o que estava exatamente em sua mira neste instante.
- Como o servidor está recebendo todos momentos temporais, ele pode reconstruir os eventos temporalmente ordenados, ou seja, o servidor pode reconstruir o mundo no exato momento de seu tiro, assim como para todos outros clientes.
- Sabendo o que sua arma estava mirando no momento de seu tiro, a cabeça de seu inimigo, seu presente passa a ser considerado como válido no servidor, já que ele compensa esta diferenca.
- O servidor processa o tiro e transmite para todos os clientes, deixando seu oponente furioso por ter levado um headshot.
E é no passo dois que a compensacão de lag ocorre.
Conclusão
Primeira coisa que fizemos foi entender qual o grande problema do desenvolvimento de servidores para jogos, pessoas querendo trapacear, e a partir disso entendemos qual a solução básica, um cliente que só envia comandos pro servidor e um servidor autoritário. Vimos que com um servidor autoritário alguns problemas de defasamento temporal pode ocorrer entre a informação que temos e a informação que o servidor nos obriga a ter. Para reduzir estes problemas aprendemos as técncias de predição e de reconciliação, mas descobrimos problemas de sincronização com outros clientes. Para resolver os problemas de sincronização aprendemos as técncias de dead reckoning e interpolação de entidades, que são ótimas técnicas, mas ainda podem falhar na hora que ações muito sensíveis espacialmente são executadas. Para resolver este problema de ações sensíveis, aprendemos compensação de lag, mas ainda nos falta por a mão na massa. Nos próximos capítulos vamos explorar um jogo simples de tiro e um exemplo de servidor para ele.
Multiplayer Snake Game
Vulgo jogo da cobrinha online e em Rust.
Sobre esta seção do livro
Este era um projeto que surgiu inicialmente como um livro para a Casa do Código (Visivelmente minha editora favorita), mas infelizmente a engine que eu estava usando foi "descontinuada" e decidi que queria um livro mais vivo, que pudesse se adaptar mais rapidamente a evolução das engines, assim como novas versões do Rust e o mundo de desenvolvimento de jogos. Assim, para este livro comecei a pesquisar qual seria o jogo mais didático e menos cansativo para desenvolver na Bevy Engine, a nova engine promissora de Rust, optando por traduzir este tutorial e adicionar o fator multiplayer local nele.
A engine descontinuada que menciono é a https://amethyst.rs/.
Organização
- Sobre a Bevy Engine e configurando uma janela vazia.
- ECS
- Snake Game
- Multiplayer local de Snake Game
Sobre a Bevy
Bevy engine é uma das game engines mais promissoras do mercado e um grande esforço coletivo para a comunidade rust_gamedev. Se trata de uma engine orientada a dados, gratuíta e open source, sob as licenças Apache e MIT, ou seja, perfeita para qualquer projeto. Ela possui como objetivos de design:
- Um conjunto completo de features para jogos 2D e 3D, podendo inclusive ser aplicada para outros objetivos.
- Simples e poderosa, mas mantendo o fácil aprendizado.
- Orientada a dados utilizando o paradigma ECS (Entity component system, no próximo capítulo).
- Modular, use o que quiser, adicione o que quiser, e substitua o que quiser.
- Rápida, paralela e em Rust <3.
- Compilação rápida
A atual versão da Bevy é e este livro foi desenvolvido com a versão 0.7
, mas contém guias de migração para as versões 0.8
e 0.9
. A compatibilidade com Rust esta garantida para a versão 1.66
.
Iniciando o projeto
Para iniciar um projeto com a Bevy é necessário possuir Rust e Cargo, caso você não possua basta fazer download em https://rustup.rs/.
Vamos iniciar nosso projeto com um simples cargo new bevy-snake --bin
, que gera um projeto executável em Rust chamado bevy-snake
. Este projeto vai possuir um Cargo.toml
(onde os metadados do projeto estão localizados), um src/main.rs
e um .gitignore
:
// src/main.rs fn main() { println!("Hello, world!"); }
# .gitignore
/target
Agora adicionamos versão atual da bevy (bevy = "0.7"
) a seção [dependencies]
do Cargo.toml. Adicionamos também a crate de aleatoriedade rand
:
[dependencies]
bevy = "0.7"
rand = "0.7"
Com essas mudanças no Cargo.toml
podemos começar a usar o prelude
da bevy e criar nosso primeiro app com:
use bevy::prelude::*; fn main() { App::new().run(); }
Instanciando uma Janela
Instanciar uma janela com a Bevy é bastante trivial e pode ser feito através do uso de plugins, neste caso o DefaultPlugins
contém um conjunto básico de plugins que tornam a bevy operacional:
fn main() { App::new().add_plugins(DefaultPlugins).run(); }
Agora se executarmos cargo run
veremos uma janela com fundo cinza. Por padrão, os plugins da Bevy não incluem camera, pois o uso de camera é muito variado em jogos, assim, precisamos criar nosso próprio sistema de cameras. Usaremos uma camera ortográfica 2D com o commando OrthographicCameraBundle::new_2d()
em uma função que fará a configuração do sistema de cameras inicial alterando a variável do tipo mut Commands
. Commands
é um tipo muito comum ao escrever sistemas com a Bevy e é usado para enfileirar comandos com o objetivo de modificar o mundo (que chamaremos de world
) e os recursos (que chamaremos de resources
). Assim, na função a seguir, setup_camera
, receberemos como argumento mut commands: Commands
e utilizaremos ele para instanciar (chamado de spawn
) uma nova entidade bundle com os componentes de uma câmera 2D ortográfica:
#![allow(unused)] fn main() { fn setup_camera(mut commands: Commands) { commands.spawn_bundle(OrthographicCameraBundle::new_2d()); } }
E agora basta adicionar esse função ao nosso App
através de um add_startup_system
:
fn main() { App::new() .add_startup_system(setup_camera) .add_plugins(DefaultPlugins) .run(); } fn setup_camera(mut commands: Commands) { commands.spawn_bundle(OrthographicCameraBundle::new_2d()); }
Plugins
A Bevy é pensada de forma que todas suas partes sejam modularizáveis, assim, todas as core features da engine são implementadas como plugins que podem ser substituídos, evoluídos e customizados, além disso, os próprios jogos são encarados como plugins. Assim, se você não precisar de uma UI, basta não registrar o sistema de UI, quer um sistema de UI diferente, registre o seu próprio. Para o caso de servidores, basta não registrar o plugin RenderPlugin
.
Caso você não precise de uma experiência tão avançada com a Bevy, é possível utilizar o DefaultPlugins
que utilizamos anteriormente, que possui sistemas como Rendering, gerenciamento de assets, sistema de UI, janelas e gerenciamento de entrada de dados.
Criando um Plugin
Para criar um plugin simplesmente precisamos implementar a trait Plugin
em um tipo que comporte as informações necessárias. No caso do plugin que vamos implementar é apenas um hello world
para plugins, então não precisamos de dados, criando apenas um
#![allow(unused)] fn main() { pub struct HelloPlugin; impl Plugin for HelloPlugin { fn build(&self, app: &mut App) { // lógica do plugin } } }
Agora precisamos de uma função que nosso sistema vai executar, neste caso um simples println
:
#![allow(unused)] fn main() { fn hello_plugin() { println!("hello plugin!"); } }
E adicionamos essa função como um startup_system
no nosso plugin:
#![allow(unused)] fn main() { impl Plugin for HelloPlugin { fn build(&self, app: &mut App) { app.add_startup_system(hello_plugin); } } }
Por último, basta adicionarmos nosso plugin ao App
principal e executar cargo run
:
fn main() { App::new() .add_startup_system(setup_camera) .add_plugin(HelloPlugin) .add_plugins(DefaultPlugins) .run(); }
Veremos algo no terminal como:
2022-06-20T05:28:52.725036Z INFO bevy_render::renderer: AdapterInfo { name: "AMD Radeon Pro 5500M", vendor: 0, device: 0, device_type: DiscreteGpu, backend: Metal }
hello plugin!
Entity Component System (ECS)
O sistema de gerenciamento de dados da Bevy é chamado de Entity Component System, ou ECS, e sua principal característica é a simplicidade do gerenciamento de dados. Uma boa analogia ao seu funcionamento é com bancos de dados tabulares, na qual os componentes, components, são os tipos de dados, ou colunas, e as entidades, entities, são as linhas, mais especificamente o ID das linhas. Por exemplo, você poderia ter diversas entidades com o componente Health
e cada entidade possui um component Health
diferente, já que NPCs, players e objetos do mundo podem ter Health
s diferentes (health significa vida em inglês). Assim, o conjunto de componentes que uma entidade possui é chamado de arquétipo, Archetype.
Considerando a entidade player possuindo componentes como vida, força, ataque, defesa, inventario, as entidades inimigos com vida, força, ataque, defesa, inteligência, e a entidade planta com apenas vida, fica muito fácil escrever uma lógica de jogo que gerencia esses tipos de entidades, como verificar se uma entidade com vida encontrou outra entidade com vida, simplificando muito a criação de lógicas de jogo. Essa lógica de gerenciamento é chamado sistema, system. Estes sistemas são executados em paralelo pelo smart scheduling algorithm da Bevy e com isso devemos manter nossas entidades o mais horizontal possível, evitando grandes componentes com muitos campos. Isso influência muito a performance do sistema, pois quando mais vertical a entidade mais problemas de acesso aos dados em paralelo teremos.
Para você que vem da orientação a objectos, no paradigma de ECS é mais comum possuir uma entidade com diversos componentes, como a entidade Player que possui os componentes Vida(u32), Posição(x, y, z), Direção(x, y, z), Escala(x, y, z), Rotação(x, y, z), Defesa(u16), Ataque(u16), Força(u16) em vez de uma classe
Player
com os campos vida: u32, posição: [x, y, z], direção: [x, y, z], escala: [x, y, z], rotação: [x, y, z], defesa: u16, ataque: u16, força: u16:
#![allow(unused)] fn main() { // Prefira isso: // Entidade Player; #[derive(Component)] pub struct Vida(u32) #[derive(Component)] pub struct Posição(x, y, z) #[derive(Component)] pub struct Direção(x, y, z) #[derive(Component)] pub struct Escala(x, y, z) #[derive(Component)] pub struct Rotação(x, y, z) #[derive(Component)] pub struct Defesa(u16) #[derive(Component)] pub struct Ataque(u16) #[derive(Component)] pub struct Força(u16) // Em vez disso: pub struct Player { pub vida: u32, pub posição: [x, y, z], pub direção: [x, y, z], pub escala: [x, y, z], pub rotação: [x, y, z], pub defesa: u16, pub ataque: u16, pub força: u16, } }
Criando entidades
Entidades são simplesmente IDs inteiros associados a um comando spawn
de commands
, commands.spawn(...)
e para adicionar componentes basta utilizarmos a diretica insert
em um spawn
:
#![allow(unused)] fn main() { fn spawn_entity(mut commands: Commands) { commands .spawn() .insert(Label("Player")) .insert(Vida(10)) .insert(Posição(0, 2, 0)) .insert(Direção(0, 2, 0)) .insert(Escala(0, 2, 0)) .insert(Rotação(0, 2, 0)) .insert(Defesa(10)) .insert(Ataque(10)) .insert(Força(10)); } }
Além disso, existe o conceito de bundles. Bundles são como templates que tornam a criação de entidades com diversos componentes mais simples:
#![allow(unused)] fn main() { #[derive(Bundle)] struct Transform { posição: Posição(x, y, z), direção: Direção(x, y, z), escala: Escala(x, y, z), rotação: Rotação(x, y, z), } #[derive(Bundle)] struct Player { vida: u32, defesa: u16, ataque: u16, força: u16, #[bundle] // Nested bundles transform: Transform } }
Como podemos ver em transform: Transform
, bundles também podem ser encadeados. Tuplas arbitrárias também são consideradas bundles. Note, que bundles não podem ser consultados com uma query.
Recursos (Resources)
Recursos são um tipo de instância que permite armazenar um tipo de dado de forma global, independente de entidades, e qualquer tipo Rust pode ser usado como um recurso independente de implementação de traits. Existem duas formas de inicializar recursos, a primeira é definindo a trait Default
para eles, quando eles possuem um tipo de dado simples, já a segunda é implementando a trait FromWorld
que permite atuar sobre o recurso utilizando valores de World
:
#![allow(unused)] fn main() { #[derive(Default)] struct StartingLevel(usize); struct MyFancyResource { /* stuff */ } impl FromWorld for MyFancyResource { fn from_world(world: &mut World) -> Self { // You have full access to anything in the ECS from here. // For instance, you can mutate other resources: let mut x = world.get_resource_mut::<MyOtherResource>().unwrap(); x.do_mut_stuff(); MyFancyResource { /* stuff */ } } } }
E para inicializar seus recursos em um App basta usar a função insert_resource
:
fn main() { App::new() // Caso implemente uma das traits `Default` ou `FromWorld` .init_resource::<MyFancyResource>() // se for necessário definir o valor inicial .insert_resource(StartingLevel(3)) // ... .run(); }
A decisão de quando usar recursos ou entity/component é baseada na forma e no momento em que este dado vai ser acessado, mas considerando algo como um jogo com uma unica entidade, pode ainda ser útil utilizar o padrão ECS, pois ele permite maior flexibildiade e compartilhamento de dados, que podem ser muito úteis para a evolução do jogo.
Sistemas (Systems)
Sistemas são funções que a desenvolvedora escreve com o objetivo de ser uma unidade de lógica do jogo atuando sobre as entidades e os componentes. Os sistemas são executados e gerenciados pelas Bevy, mas somente podem ser usados com parâmetros especiais. Os parâmetros especiais são:
Res/ResMut
para acessar recursos.Query
para acessar componentes de uma entidade.Commands
para criar e destruir entidades, componentes e recursos.EventWriter/EventReader
para enviar e receber eventos.
Um sistema pode conter no máximo 16 parâmetros, caso seja preciso mais parâmetros pode se agrega-los em tuplas de no máximo 16 parâmetros. Caso estes limites não sejam suficiente, é possível fazer tuplas de tuplas.
#![allow(unused)] fn main() { fn complex_system( (a, mut b): (Res<ResourceA>, ResMut<ResourceB>), mut c: Option<ResMut<ResourceC>>, ) { if let Some(mut c) = c { // lógica } } }
No sistema a cima ResourceA
é um recurso imutável e esta compartilhando uma tupla com ResourceB
que é um recurso mutável. Já ResourceC
é um recurso que pode não existir e por isso está englobado por um tipo Optional<T>
.
Existem dois tipos de funções para executar sistemas na Bevy
fn main() { App::new() // ... // sistemas executados apenas quando o App é lançado .add_startup_system(init_menu) .add_startup_system(debug_start) // sistemas executados todos os frames .add_system(move_player) .add_system(enemies_ai) // ... .run(); }
Agora vamos começar a implementar nosso snake game e aprofundar nossos conhecimentos em bevy.
Referência: unofficial bevy guide
A Cabeça da Cobra
Para começar o jogo precisamos do primeiro componente, neste caso a cabeça da cobra, que definirá os próximos possíveis passos, assim como para onde os blocos seguintes se moverão. Este primeiro componente se chamará SnakeHead
e será uma struct vazia com a trait Component
associada a ela:
#![allow(unused)] fn main() { #[derive(Component)] pub struct SnakeHead; }
A função de SnakeHead
é basicamente ser um marcador para as entidades do tipo snake, que nos permitirá filtrar as estas entidades quando formos fazer queries com os players. Muitos componentes não precisam de estados e podem funcionar apenas como marcadores, um padrão bastante comum no mundo ECS, já que optamos por uma estratégia de has a (possui um) em vez de is a (é um, da orientação a objetos). Outro detalhe importante é a adição de uma cor específica para a cabeça da cobra const SNAKE_HEAD_COLOR: Color = Color::rgb(0.7, 0.7, 0.7);
.
Nosso próximo passo é gerar uma entidade snake, que possui um componente do tipo SnakeHead
, e essa entidade pode ser gerada adicionando um sistema inicial com add_startup_system(spawn_snake)
, dada a função spawn_snake
:
use bevy::prelude::*; const SNAKE_HEAD_COLOR: Color = Color::rgb(0.7, 0.7, 0.7); fn main() { App::new() .add_startup_system(setup_camera) .add_startup_system(spawn_snake) .add_plugins(DefaultPlugins) .run(); } fn setup_camera(mut commands: Commands) { commands.spawn_bundle(OrthographicCameraBundle::new_2d()); } #[derive(Component)] pub struct SnakeHead; fn spawn_snake(mut commands: Commands) { commands .spawn_bundle(SpriteBundle { sprite: Sprite { color: SNAKE_HEAD_COLOR, ..default() }, transform: Transform { scale: Vec3::new(10.0, 10.0, 10.0), ..default() }, ..default() }) .insert(SnakeHead); }
SpriteBundle
SpriteBundle
é um tipo de componente que agrega características comuns a uma entidade que utiliza sprites como o próprio sprite (especificidades da imagem), transform (relação de posição, escala e rotação), visibilidade, transform global e o manuseio de imagens.
Neste caso, não temos nenhuma imagem específica como sprite, mas definimos um transform com uma escala de 10 x 10 x 10
pixels e uma cor de filtro acinzentada para a região definida pelo transform, as outras propriedades foram definidas como ..default()
. Ao executarmos cargo run
o resultado é algo como:
Nosso primeiro teste
No mundo moderno, jogos sem testes estão fadados ao fracasso. Não estou dizendo que todos os jogos possuem uma bateria maravilhosa de testes automatizados, mas desde que escrevi o livro Lean Game Development até hoje, o mercado de games AAA mudou muito. Hoje em dia vejo jogos sendo desenvolvidos com TDD e com QA advogando por testes automatizados de gameplay emt todos os sistemas, garantindo uma jogabilidade equilibrada/desejada em qualquer plataforma. Hoje em dia um jogo, middleware, game server ou ferramenta sem nenhum teste esta fadado ao fracasso por conta do número excessivo de bugs e clientes infelizes. Sendo assim, é importante ter uma noção de como testar minimamente seus sistemas com a Bevy. Sendo assim, vamos aprender a escrever o teste mais simples possível, verificar se nosso sistema spawn_snake
de fato adiciona um componente SnakeHead
à entidade desejada.
Primeiro passo do teste será mover tudo que é relacionado a snake
para um módulo chamado snake.rs
:
main.rs
:
use bevy::prelude::*; mod snake; use snake::spawn_snake; fn main() { App::new() .add_startup_system(setup_camera) .add_startup_system(spawn_snake) .add_plugins(DefaultPlugins) .run(); } fn setup_camera(mut commands: Commands) { commands.spawn_bundle(OrthographicCameraBundle::new_2d()); }
snake.rs
:
#![allow(unused)] fn main() { use bevy::prelude::*; const SNAKE_HEAD_COLOR: Color = Color::rgb(0.7, 0.7, 0.7); #[derive(Component)] pub struct SnakeHead; pub fn spawn_snake(mut commands: Commands) { commands .spawn_bundle(SpriteBundle { sprite: Sprite { color: SNAKE_HEAD_COLOR, ..default() }, transform: Transform { scale: Vec3::new(10.0, 10.0, 10.0), ..default() }, ..default() }) .insert(SnakeHead); } }
Agora em Snake vamos criar um teste dentro de um módulo de testes (#[cfg(test)] mod test {...}
) que verifique se um componente SnakeHead
está presente:
#![allow(unused)] fn main() { #[cfg(test)] mod test { use super::*; #[test] fn entity_has_snake_head() { // 1 Inicialização do App let mut app = App::new(); // 2 Adicionar o `spawn_snake` startup system app.add_startup_system(spawn_snake); // 3 Executar todos os sistemas pelo menos uma vez app.update(); // 4 Fazer uma query por entidades que contenham o componente `SnakeHead` let mut query = app.world.query_filtered::<Entity, With<SnakeHead>>(); // 5 Verificar se a contagem de componentes da query foi igual a 1 assert_eq!(query.iter(&app.world).count(), 1); } } }
Descrevendo o teste entity_has_snake_head
(verifica se entidade possui componente snake head) temos como primeiro passo (1
) criar um App
mutável para podermos adicionar sistemas como o spawn_snake
(2
) e executarmos todos os sistemas pelo menos uma vez com app.update()
(3
). O próximo passo é realizarmos uma query
(4
) no sistema de ECS para procurarmos por uma entidade que possua o componente SnakeHead
(With<SnakeHead>
). Com o resultado desta query
verificamos se a quantidade de entidades que possuem o componente SnakeHead
é igual a 1
(5
).
Queries
O principal objetivo de queries é nos permitir acessar componentes de entidades. No código a seguir, temos uma query do tipo Query<(&Health, &mut Transform, Option<&Player>)>
que representa todas as entidades que possuam Health
e Transform
, com a propriedade Health
sendo apenas leitura e a propriedade Transform
sendo mutável. Além disso, caso o componente Player
esteja presente, permite a leitura dele. Depois disso iteramos sobre todos os ítens dessa query, de forma mutável, para podermos alterar a propriedade transform, (health, mut transform, player) in query.iter_mut()
. Por último, caso o componente Player
esteja presente, sabemos que esta entidade é do tipo player e aplicamos uma lógica extra.
#![allow(unused)] fn main() { fn check_zero_health( mut query: Query<(&Health, &mut Transform, Option<&Player>)>, ) { // Obtem todas as entidades do tipo for (health, mut transform, player) in query.iter_mut() { eprintln!("Entity at {} has {} HP.", transform.translation, health.hp); // centraliza se `hp` é menor ou igual a `0.0` if health.hp <= 0.0 { transform.translation = Vec3::ZERO; } if let Some(player) = player { // entidade é do tipo `Player` // lógica extra } } } }
para obter o ID de uma entidade com queries basta adicionar Entity
a query e a variável entity_id
corresponderá ao id:
#![allow(unused)] fn main() { // adicione `Entity` a `Query` para obter os IDs fn query_entities(q: Query<(Entity, /* ... */)>) { for (entity_id, /* ... */) in q.iter() { // `entity_id` é o ID da entidade que estamos acessando. } } }
Caso exista certeza que uma query vai identificar apenas uma entidade, é possível utilizar single
e single_mut
para acessar seus componentes:
#![allow(unused)] fn main() { fn query_player(mut q: Query<(&Player, &mut Transform)>) { let (player, mut transform) = q.single_mut(); // lógica } }
Outro recurso interessante de queries são os Query Filters, um tipo especial de queries que permite reduzir a quantidade de entidade que uma query retorna. Query filters se utilizam dos filtros With
e Without
para garantir que a entidade tenha (With
) ou não tenha (Without
) certos componentes. No exemplo a seguir, a query acessa todas as entidades com o componente Health
que sejam Players
amigáveis e que opcionalmente possuam PlayerName
#![allow(unused)] fn main() { fn debug_player_hp( query: Query<(&Health, Option<&PlayerName>), (With<Player>, Without<Enemy>)>, ) { for (health, name) in query.iter() { // ... } } }
Utilizando filtros
- Elementos adicionados em uma Tupla, como
(With<Player>, Without<Enemy>)
, são consideradosAND
/E
lógicos.- Para utilizar
OR
/OU
lógicos é preciso envolver as tuplas em um filtro do tipoOr<(…)>
.
Movendo a cabeça da cobra
Não existe o Snake game sem movimento, então o próximo passo é controlarmos os movimentos da cabeça da cobra com as teclas WASD
ou direcionais. Para isso, podemos começar com a movimentação para cima utilizando o teste:
#![allow(unused)] fn main() { #[test] fn snake_head_has_moved_up() { // Setup let mut app = App::new(); let default_transform = Transform {..default()}; // Adicionando sistemas app.add_startup_system(spawn_snake) .add_system(snake_movement); // Adicionando inputs de `KeyCode`s let mut input = Input::<KeyCode>::default(); input.press(KeyCode::W); app.insert_resource(input); // Executando sistemas pelo menos uma vez app.update(); // Query para obter entidades com `SnakeHead` e `Transform` let mut query = app.world.query::<(&SnakeHead, &Transform)>(); // Verificando se o valor de Y no `Transform` mudou query.iter(&app.world).for_each(|(_head, transform)| { assert!(default_transform.translation.y < transform.translation.y); assert_eq!(default_transform.translation.x, transform.translation.x); }) } }
Neste teste adicionamos um Transform
com valores padrão de translation
para comparar quando o transform da query mudar, adicionamos um novo sistema de movimento add_system(snake_movement)
e criamos um recurso que gerencia inputs de teclado Input::<KeyCode>::default()
, na qual setamos seu evento press
como KeyCode::W
. Para resolver este teste precisamos criar o sistema snake_movement
, que é bastante trivial neste caso, apenas um sistema que busca por um query contendo &SnakeHead
e &Transform
, depois modifica o valor de Y de forma que sempre aumente:
// snake.rs pub fn snake_movement(mut head_positions: Query<(&SnakeHead, &mut Transform)>) { for (_head, mut transform) in head_positions.iter_mut() { transform.translation.y += 1.; } } // main.rs // ... mod snake; use snake::{spawn_snake, snake_movement}; fn main() { App::new() .add_startup_system(setup_camera) .add_startup_system(spawn_snake) .add_plugins(DefaultPlugins) .add_system(snake_movement) .run(); } // ...
Controlando a direção de movimento
Nosso movimento atual está longe de ser realista ou funcional, para isso precisamos que a cobra se movimente com base nas teclas wasd
e podemos começar com um teste que move a cobra 1 unidade para cima, verificando que apenas o y
mudou em relacao ao original, depois uma unidade para direita, verificando que apenas o x
mudou em relação ao anterior. Por último, um novo teste movendo para baixo e para esquerda, verificando se as posições são inferiores às originais em x
e y
. Assim, o primeiro teste fica:
#![allow(unused)] fn main() { #[test] fn snake_head_moves_up_and_right() { // Setup let mut app = App::new(); let default_transform = Transform {..default()}; // Adiciona systemas app.add_startup_system(spawn_snake) .add_system(snake_movement); // Testa movimento para cima let mut up_transform = Transform {..default()}; let mut input = Input::<KeyCode>::default(); input.press(KeyCode::W); app.insert_resource(input); app.update(); let mut query = app.world.query::<(&SnakeHead, &Transform)>(); query.iter(&app.world).for_each(|(_head, transform)| { assert!(default_transform.translation.y < transform.translation.y); assert_eq!(default_transform.translation.x, transform.translation.x); up_transform = transform.to_owned(); }); // Testa movimento para direita let mut input = Input::<KeyCode>::default(); input.press(KeyCode::D); app.insert_resource(input); app.update(); let mut query = app.world.query::<(&SnakeHead, &Transform)>(); query.iter(&app.world).for_each(|(_head, transform)| { assert_eq!(up_transform.translation.y , transform.translation.y); assert!(up_transform.translation.x < transform.translation.x); }) } }
Ao executarmos este teste percebemos que a linha assert_eq!(up_transform.translation.y , transform.translation.y);
falha pois nosso transform.translation.y
está maior que o anterior, que faz sentido, já que nosso sistema de movimento está apenas aumentando o y
a cada update. Para resolvermos isso, podemos adicionar os comandos para se mover com w
e com d
:
#![allow(unused)] fn main() { // snake.rs pub fn snake_movement( keyboard_input: Res<Input<KeyCode>>, mut head_positions: Query<(&SnakeHead, &mut Transform)> ) { for (_head, mut transform) in head_positions.iter_mut() { if keyboard_input.pressed(KeyCode::D) { transform.translation.x += 1.; } if keyboard_input.pressed(KeyCode::W) { transform.translation.y += 1.; } } } }
Teste passando, então podemos fazer o segundo teste, movimento para baixo e para esquerda. O teste é basicamente igual ao anterior, mas reduzimos algumas linhas:
#![allow(unused)] fn main() { #[test] fn snake_head_moves_down_and_left() { // Setup let mut app = App::new(); let default_transform = Transform {..default()}; app.add_startup_system(spawn_snake) .add_system(snake_movement); // Movimenta para baixo let mut input = Input::<KeyCode>::default(); input.press(KeyCode::S); app.insert_resource(input); app.update(); // Movimenta para esquerda let mut input = Input::<KeyCode>::default(); input.press(KeyCode::A); app.insert_resource(input); app.update(); // Assert let mut query = app.world.query::<(&SnakeHead, &Transform)>(); query.iter(&app.world).for_each(|(_head, transform)| { assert!(default_transform.translation.y > transform.translation.y); assert!(default_transform.translation.x > transform.translation.x); }) } }
Como esperado, o teste falha e podemos implementar as condições que faltam de pressionar o teclado, s
e a
:
#![allow(unused)] fn main() { pub fn snake_movement( keyboard_input: Res<Input<KeyCode>>, mut head_positions: Query<(&SnakeHead, &mut Transform)> ) { for (_head, mut transform) in head_positions.iter_mut() { if keyboard_input.pressed(KeyCode::D) { transform.translation.x += 1.; } if keyboard_input.pressed(KeyCode::W) { transform.translation.y += 1.; } if keyboard_input.pressed(KeyCode::A) { transform.translation.x -= 1.; } if keyboard_input.pressed(KeyCode::S) { transform.translation.y -= 1.; } } } }
Tudo passa e podemos ir para o próximo passo, explicar e melhorar este código. O argumento keyboard_input
é um recurso que contém os eventos relacionados a tecla que foi pressionada no input
, ou seja, Res<Input<KeyCode>>,
. Nossa query faz sentido e está funcional, porém, como não estamos utilizando o componente SnakeHead
, representado por _head
, podemos mudar nossa query para Query<&mut Transform, With<SnakeHead>>
, que altera nosso código para utilizar apenas o transform como variável:
#![allow(unused)] fn main() { pub fn snake_movement( keyboard_input: Res<Input<KeyCode>>, mut head_positions: Query<&mut Transform, With<SnakeHead>> ) { for mut transform in head_positions.iter_mut() { if keyboard_input.pressed(KeyCode::D) { transform.translation.x += 1.; } if keyboard_input.pressed(KeyCode::W) { transform.translation.y += 1.; } if keyboard_input.pressed(KeyCode::A) { transform.translation.x -= 1.; } if keyboard_input.pressed(KeyCode::S) { transform.translation.y -= 1.; } } } }
Como mencionamos antes sobre o With
, ele nos permite buscar todas as entidades que possuam o componente SnakeHead
, mas explícita para a Bevy que não nos importamos com o conteúdo de SnakeHead
, apenas com o Transform
. Isso é importante pois quanto menos componentes o sistema precisar acessar, mais a bevy conseguirá paralelizar as coisas.
CI
Uma coisa bastante importante enquanto desenvolvemos é termos um sistema de integração contínua executando. No caso do Rust no Github eu recomendo utilizar o Github Actions e minha configuração base para projetos Rust é:
name: Rust
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "*" ]
env:
CARGO_TERM_COLOR: always
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Install alsa and udev
run: sudo apt-get update; sudo apt-get install --no-install-recommends libasound2-dev libudev-dev libwayland-dev libxkbcommon-dev
- name: Build
run: cargo build --release --verbose
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Install alsa and udev
run: sudo apt-get update; sudo apt-get install --no-install-recommends libasound2-dev libudev-dev libwayland-dev libxkbcommon-dev
- name: tests
run: cargo test -- --nocapture
fmt:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: FMT
run: cargo fmt -- --check
clippy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Install alsa and udev
run: sudo apt-get update; sudo apt-get install --no-install-recommends libasound2-dev libudev-dev libwayland-dev libxkbcommon-dev
- name: install-clippy
run: rustup component add clippy
- name: clippy
run: cargo clippy -- -W clippy::pedantic --deny "warnings"
Ao executarmos o CI, percebemos que a formatação não estava correta, que pode ser corrigida com cargo fmt
, e há algumas sugestões de linting em relação a nomenclatura das funções e structs no módulo e declaração de argumentos. A questão de nomenclatura solicita que funções e structs não comecem com o nome do módulo. A declaração de argumentos solicita que o tipo de keyboard_input
seja passado como referência keyboard_input: &Res<Input<KeyCode>>
, porém isso quebra a injeção de recursos da bevy, necessitando assim que o lint seja descartado com #[allow(clippy::needless_pass_by_value)]
. Meu único problema com a questão de nomenclatura é perder o contexto de que os sistemas e as structs quando utilizamos importações absolutas em vez de qualificadas. A solução é utilizar importações qualificadas. O código ficou assim:
// Snake.rs use bevy::prelude::*; const SNAKE_HEAD_COLOR: Color = Color::rgb(0.7, 0.7, 0.7); #[derive(Component)] pub struct Head; pub fn spawn_system(mut commands: Commands) { commands .spawn_bundle(SpriteBundle { sprite: Sprite { color: SNAKE_HEAD_COLOR, ..default() }, transform: Transform { scale: Vec3::new(10.0, 10.0, 10.0), ..default() }, ..default() }) .insert(Head); } #[allow(clippy::needless_pass_by_value)] pub fn movement_system( keyboard_input: Res<Input<KeyCode>>, mut head_positions: Query<&mut Transform, With<Head>>, ) { for mut transform in head_positions.iter_mut() { if keyboard_input.pressed(KeyCode::D) { transform.translation.x += 1.; } if keyboard_input.pressed(KeyCode::W) { transform.translation.y += 1.; } if keyboard_input.pressed(KeyCode::A) { transform.translation.x -= 1.; } if keyboard_input.pressed(KeyCode::S) { transform.translation.y -= 1.; } } } #[cfg(test)] mod test { use super::*; #[test] fn entity_has_snake_head() { // Setup app let mut app = App::new(); // Add startup system app.add_startup_system(spawn_system); // Run systems app.update(); let mut query = app.world.query_filtered::<Entity, With<Head>>(); assert_eq!(query.iter(&app.world).count(), 1); } #[test] fn snake_head_has_moved_up() { // Setup let mut app = App::new(); let default_transform = Transform { ..default() }; // Add systems app.add_startup_system(spawn_system) .add_system(movement_system); // Add input resource let mut input = Input::<KeyCode>::default(); input.press(KeyCode::W); app.insert_resource(input); // Run systems app.update(); let mut query = app.world.query::<(&Head, &Transform)>(); query.iter(&app.world).for_each(|(_head, transform)| { assert!(default_transform.translation.y < transform.translation.y); assert_eq!(default_transform.translation.x, transform.translation.x); }) } #[test] fn snake_head_moves_up_and_right() { // Setup let mut app = App::new(); let default_transform = Transform { ..default() }; // Add systems app.add_startup_system(spawn_system) .add_system(movement_system); // Move Up let mut up_transform = Transform { ..default() }; let mut input = Input::<KeyCode>::default(); input.press(KeyCode::W); app.insert_resource(input); app.update(); let mut query = app.world.query::<(&Head, &Transform)>(); query.iter(&app.world).for_each(|(_head, transform)| { assert!(default_transform.translation.y < transform.translation.y); assert_eq!(default_transform.translation.x, transform.translation.x); up_transform = transform.to_owned(); }); // Move Right let mut input = Input::<KeyCode>::default(); input.press(KeyCode::D); app.insert_resource(input); app.update(); let mut query = app.world.query::<(&Head, &Transform)>(); query.iter(&app.world).for_each(|(_head, transform)| { assert_eq!(up_transform.translation.y, transform.translation.y); assert!(up_transform.translation.x < transform.translation.x); }) } #[test] fn snake_head_moves_down_and_left() { // Setup let mut app = App::new(); let default_transform = Transform { ..default() }; // Add systems app.add_startup_system(spawn_system) .add_system(movement_system); // Move down let mut input = Input::<KeyCode>::default(); input.press(KeyCode::S); app.insert_resource(input); app.update(); // Move Left let mut input = Input::<KeyCode>::default(); input.press(KeyCode::A); app.insert_resource(input); app.update(); // Assert let mut query = app.world.query::<(&Head, &Transform)>(); query.iter(&app.world).for_each(|(_head, transform)| { assert!(default_transform.translation.y > transform.translation.y); assert!(default_transform.translation.x > transform.translation.x); }) } } // Main.rs use bevy::prelude::*; mod snake; fn main() { App::new() .add_startup_system(setup_camera) .add_startup_system(snake::spawn_system) .add_plugins(DefaultPlugins) .add_system(snake::movement_system) .run(); } fn setup_camera(mut commands: Commands) { commands.spawn_bundle(OrthographicCameraBundle::new_2d()); }
A seguir vamos criar o conceito de Grid.
Grade de Movimento
Nosso sistema de movimentação tem utilizado coordenadas da janela para fazer a movimentação, sendo o ponto (0,0)
o centro da janela e cada unidade corresponde a um pixel, porém o snake game utiliza um sistema de grade. Assim, precisamos definir uma grade básica com tamanho da grade de 10 x 10
e células da grade com mais de 1 pixel para evitar janelas de 10 px por 10 px. Além disso, definir uma grade a aprtir do centro é bastante complexo, por isso vamos utilizar nosso próprio sistema de coordenadas e criar um sistema que faça a conversão. Nosso primeiro passo é adicionar constantes referentes ao tamnho da arena. É importante que estas constantes sejam definidas fora, pois quando iniciarmos o modo multiplayer 10 x 10
será muito pequena.
// main.rs
mod snake;
const GRID_WIDTH: u32 = 10;
const GRID_HEIGHT: u32 = 10;
fn main() {
// ...
}
As constantes GRID_WIDTH
e GRID_HEIGHT
referemm a largura da arena e a altura da arena, respectivamente. Agora criamos um novo módulo components
que é responsável por gerenciar componentes básicos e transversair do jogo, como posição (Position
) e tamanho de célula (Size
):
// main.rs
mod snake;
pub mod components;
const GRID_WIDTH: u32 = 10;
const GRID_HEIGHT: u32 = 10;
// ...
// components.rs
use bevy::prelude::Component;
#[derive(Component, Clone, Debug, PartialEq, Eq)]
pub struct Position {
pub x: i32,
pub y: i32,
}
#[derive(Component, Debug, PartialEq)]
pub struct Size {
pub width: f32,
pub height: f32,
}
impl Size {
pub fn square(x: f32) -> Self {
Self {
width: x,
height: x,
}
}
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn sized_square_is_created_calling_square_fn() {
let expected = Size {width: 3.14, height: 3.14};
let actual = Size::square(3.14);
assert_eq!(actual, expected);
}
}
No arquivo de components precisamos apenas importar a trait Component
e definir as structs Position
com x, y
e Size
com width,height
. O único teste presente é o sized_square_is_created_calling_square_fn
pois ele testa se um quadrado de lado f
é criado quando chamamos a função Size::square
. Ou seja, Size::square
é um método para ajudar a gerar células, ou qualquer outra coisa que tenha tamanho, de altura e largura iguais. Outra coisa importante de salientar são as várias traits derivadas em Position
, no futuro elas devem nos ajudar a utilizar Position
. Próximo passo é incorporar estes componentes na cobra que temos:
#![allow(unused)] fn main() { use crate::components::{Position, Size}; const SNAKE_HEAD_COLOR: Color = Color::rgb(0.7, 0.7, 0.7); #[derive(Component)] pub struct Head; pub fn spawn_system(mut commands: Commands) { commands .spawn_bundle(SpriteBundle { sprite: Sprite { color: SNAKE_HEAD_COLOR, ..default() }, transform: Transform { scale: Vec3::new(10.0, 10.0, 10.0), ..default() }, ..default() }) .insert(Head) // Remover ; .insert(Position { x: 5, y: 5 }) // <- .insert(Size::square(0.8)); // <- } }
Se executarmos os testes agora, vamos ver que não há nenhuma alteração significativa, pois todos os testes seguem passando. Agora precisamos de uma função auxiliar para gerenciar a escala de cáda célula da cobra e da grade, assim como uma função que faça a correspondência entre posição na grade e posição na janela. Vamos começar com a mais fácil, escala, que chamaremos de size_scaling
. Antes, criamos um módulo chamado grid
e movemos GRID_WIDTH
e GRID_HEIGHT
para este módulo:
// grid.rs
use bevy::prelude::*;
use crate::components::Size;
const GRID_WIDTH: u32 = 10;
const GRID_HEIGHT: u32 = 10;
pub fn size_scaling(windows: Res<Windows>, mut q: Query<(&Size, &mut Transform)>) {
let window = windows.get_primary().unwrap();
for (sprite_size, mut transform) in q.iter_mut() {
scale_sprite(transform.as_mut(), sprite_size, window);
}
}
fn scale_sprite(transform: &mut Transform, sprite_size: &Size, window: &Window) {
transform.scale = Vec3::new(
sprite_size.width / GRID_WIDTH as f32 * window.width() as f32,
sprite_size.height / GRID_HEIGHT as f32 * window.height() as f32,
1.0,
);
}
#[cfg(test)]
mod test {
use bevy::window::WindowId;
use raw_window_handle::{RawWindowHandle, WebHandle};
use crate::{components::Size};
use super::*;
#[test]
fn transform_has_correct_scale_for_window() {
// Setup
let expected_transform = Transform { scale: Vec3::new(20., 20., 1.,),..default() };
let mut default_transform = Transform { scale: Vec3::new(2., 3., 4.,),..default() };
let sprite_size = Size::square(1.);
// Create window
let mut descriptor = WindowDescriptor::default();
descriptor.height = 200.;
descriptor.width = 200.;
let raw_window_handle = RawWindowHandle::Web(WebHandle::empty());
let window = Window::new(WindowId::new(), &descriptor, 200, 200, 1., None, raw_window_handle);
// Apply scale
scale_sprite(&mut default_transform, &sprite_size, &window);
assert_eq!(default_transform, expected_transform);
}
}
Infelizmente, o recurso Windows
é bastante complicado de testar pois causa muitos problemas com o sistema de sincronização e agendamento do ECS da Bevy, por isto, neste caso não vamos testar o sistema em si, mas sim a lógica que o sistema chama, a função scale_sprite
. A lógica de size_scaling
é a seguinte: Se algo possui uma Size.width
e uma Size.height
, neste caso sprite_size.width
e sprite_size.height
, igual a 1.0, em uma grade de tamanho 40, em uma janela de tamanho 400 px, então a largura deveria ser 10, pois 1.0 / 40. * 400. = 10
. Ou seja, para este teste, os valores iniciais de default_transform
não importam, apenas os valores préconfigurados de Size
, Window
, GRID_WIDTH
e GRID_HEIGHT
.
Note que no teste estamos utilizando a biblioteca raw_window_handle
, na versão 0.4.3
, para gerar as informações de window e que criamos uma janela de 200 x 200
.
A próxima função é a responsável por transformar a posição em uma coordenada de janela, então, de novo, não poderemos testar o sistema em si, apenas os blocos lógicos que serão divididos em 2:
- Função
convert
responsável por calcular o fator de conversão de posição para window. - Aplicar a conversão ao
Transform.translation
, posição na janela.
Vamos criar 2 testes para convert
:
#[test]
fn convert_position_x_for_grid_width() {
let x = convert(4., 400., GRID_WIDTH as f32);
assert_eq!(x, -20.)
}
#[test]
fn convert_position_y_for_grid_height() {
let y = convert(5., 400., GRID_HEIGHT as f32);
assert_eq!(y, 20.)
}
Estes testes tem como principal objetivo, impedir mudanças que quebrem o código, assim, sua implementação é apenas:
fn convert(pos: f32, bound_window: f32, grid_side_lenght: f32) -> f32 {
let tile_size = bound_window / grid_side_lenght;
pos / grid_side_lenght * bound_window - (bound_window / 2.) + (tile_size / 2.)
}
Calculamos o tilesize
como o tamanho da janela dividido pela quantidade de elementos da grade. Depois a posição passa a ser em relação à grade, algo como 5/ 10 = 0.5
multilicado pelo tamanho da window, porém como a bevy o ponto (0,0)
é no centro da janela, precisamos deslocal meia janela (- (bound_window / 2.)
) e centralizar o tile com + (tile_size / 2.)
.
Próximo passo é criar a função que executa a translação do valor do componente Position
para o correspondente da posição na janela no componente Transform
, como é uma função muito simples, vamos adicionar apenas um teste básico:
fn translate_position(transform: &mut Transform, pos: &Position, window: &Window) {
transform.translation = Vec3::new(
convert(pos.x as f32, window.width() as f32, GRID_WIDTH as f32),
convert(pos.y as f32, window.height() as f32, GRID_HEIGHT as f32),
0.0,
);
}
// mod test:
#[test]
fn translate_position_to_window() {
let position = Position {x: 2, y: 8};
let mut default_transform= Transform::default();
let expected = Transform { translation: Vec3::new(-100., 140., 0.,),..default() };
// Create window
let mut descriptor = WindowDescriptor::default();
descriptor.height = 400.;
descriptor.width = 400.;
let raw_window_handle = RawWindowHandle::Web(WebHandle::empty());
let window = Window::new(WindowId::new(), &descriptor, 400, 400, 1., None, raw_window_handle);
// Apply translation
translate_position(&mut default_transform, &position, &window);
assert_eq!(default_transform, expected);
}
Agora agregando tudo na função position_translation
temos:
pub fn position_translation(windows: Res<Windows>, mut q: Query<(&Position, &mut Transform)>) {
let window = windows.get_primary().unwrap();
for (pos, mut transform) in q.iter_mut() {
translate_position(transform.as_mut(), pos, window);
}
}
fn convert(pos: f32, bound_window: f32, grid_side_lenght: f32) -> f32 {
let tile_size = bound_window / grid_side_lenght;
pos / grid_side_lenght * bound_window - (bound_window / 2.) + (tile_size / 2.)
}
fn translate_position(transform: &mut Transform, pos: &Position, window: &Window) {
transform.translation = Vec3::new(
convert(pos.x as f32, window.width() as f32, GRID_WIDTH as f32),
convert(pos.y as f32, window.height() as f32, GRID_HEIGHT as f32),
0.0,
);
}
Próximo passo é adicionar os sistemas que criamos à função main utilizando o App::Builder
. Este sistema é um caso especial, pois deve ser executado após o método update já que qualquer componente que seja adicionado no update corrente será visivel somente no próximo estágio (por exemplo PostUpdate
e Draw
) e as funções position_translation
e size_scaling
somente conseguiram ver nodos novos da cobra ou comidas nova no estágio seguinte. Esta configuração especial é representada utilizando o CoreStage::PostUpdate
na função de adicionar sistemas add_system_set_to_stage
:
// main
pub mod grid;
fn main() {
App::new()
.add_startup_system(setup_camera)
.add_startup_system(snake::spawn_system)
.add_plugins(DefaultPlugins)
.add_system(snake::movement_system)
.add_system_set_to_stage(
CoreStage::PostUpdate,
SystemSet::new()
.with_system(grid::position_translation)
.with_system(grid::size_scaling),
)
.run();
}
Corrigindo a Movimentação na Grade
Até agora nosso sistema de movimento, snake::movement_system
, era baseado em movimentar o componente Transform
pela janela, porém com a implementação de grade precisamos atualizar o sistema para utilizar o componente Position
. Primeiro passo será atualizar os testes para utilizar Position
:
#![allow(unused)] fn main() { // snake.rs #[cfg(test)] mod test { // ... #[test] fn snake_head_has_moved_up() { // Setup let mut app = App::new(); let default_position = Position{x: 3, y: 4}; // <-- // Add systems app.add_startup_system(spawn_system) .add_system(movement_system); // Add input resource let mut input = Input::<KeyCode>::default(); input.press(KeyCode::W); app.insert_resource(input); // Run systems app.update(); let mut query = app.world.query::<(&Head, &Position)>(); // <-- query.iter(&app.world).for_each(|(_head, position)| { // <-- assert_eq!(&default_position, position); // <-- }) } #[test] fn snake_head_moves_up_and_right() { // Setup let mut app = App::new(); let up_position = Position{x: 3, y: 4}; // <-- // Add systems app.add_startup_system(spawn_system) .add_system(movement_system); // Move Up let mut input = Input::<KeyCode>::default(); input.press(KeyCode::W); app.insert_resource(input); app.update(); let mut query = app.world.query::<(&Head, &Position)>(); // <-- query.iter(&app.world).for_each(|(_head, position)| { // <-- assert_eq!(position, &up_position); // <-- }); let up_right_position = Position{x: 4, y: 4}; // <-- // Move Right let mut input = Input::<KeyCode>::default(); input.press(KeyCode::D); app.insert_resource(input); app.update(); let mut query = app.world.query::<(&Head, &Position)>(); // <-- query.iter(&app.world).for_each(|(_head, position)| { // <-- assert_eq!(&up_right_position, position); // <-- }) } #[test] fn snake_head_moves_down_and_left() { // Setup let mut app = App::new(); let down_left_position = Position{x: 2, y: 2}; // <-- // Add systems app.add_startup_system(spawn_system) .add_system(movement_system); // Move down let mut input = Input::<KeyCode>::default(); input.press(KeyCode::S); app.insert_resource(input); app.update(); // Move Left let mut input = Input::<KeyCode>::default(); input.press(KeyCode::A); app.insert_resource(input); app.update(); // Assert let mut query = app.world.query::<(&Head, &Position)>(); // <-- query.iter(&app.world).for_each(|(_head, position)| { // <-- assert_eq!(&down_left_position, position); // <-- }) } } }
Como agora estamos lidando com valores inteiros, nossos testes podem verificar se a posição mudou com assert_eq!
em vez de utilizar expressões lógicas com assert!
. Além disso, Position inicial com o valor Position { x: 3, y: 3 }
, por isso os valores são maiores que 0
. Ao executarmos os testes veremos que todas as positions estão iguais a ``Position { x: 3, y: 3 }`, corrigimos isso modificando a função de input:
// snake.rs
#[allow(clippy::needless_pass_by_value)]
pub fn movement_system(
keyboard_input: Res<Input<KeyCode>>,
mut head_positions: Query<&mut Position, With<Head>>,
) {
for mut position in head_positions.iter_mut() {
if keyboard_input.pressed(KeyCode::D) {
position.x += 1;
}
if keyboard_input.pressed(KeyCode::W) {
position.y += 1;
}
if keyboard_input.pressed(KeyCode::A) {
position.x -= 1;
}
if keyboard_input.pressed(KeyCode::S) {
position.y -= 1;
}
}
}
Agora sim, movimentamos o bloco célula a célula, infelizmente muito sensivel.
Configurando a Janela
Próximo passo é fazermos com que a janela seja mais coerente com o snake game, já que por padrão a janela do snake game é quadrada enquanto a janela padrão da bevy é retangular. Para fazer isso, precisamos adicionar um recurso chamado WindowDescriptor
que nos permite configurar o tamanha da tela e o título da janela:
// mains.rs
fn main() {
App::new()
.insert_resource(WindowDescriptor {
title: "Snake Game".to_string(),
width: 500.0,
height: 500.0,
..default()
}) // <--
.add_startup_system(setup_camera)
.add_startup_system(snake::spawn_system)
.add_plugins(DefaultPlugins)
.add_system(snake::movement_system)
.add_system_set_to_stage(
CoreStage::PostUpdate,
SystemSet::new()
.with_system(grid::position_translation)
.with_system(grid::size_scaling),
)
.run();
}
Outra mudança que pode ser interessante fazer é mudar o fundo da tela para ficar um pouco mais escuro, podemos fazer isso adicionando o recurso .insert_resource(ClearColor(Color::rgb(0.04, 0.04, 0.04)))
depois do WindowDescriptor
. Próximo passo é fazermos a comida aparecer.
Gerador de Comidas
Nosso próximo passo é começarmos um sistema que gere comidas de forma aleatória pela grade. O primeiro passo é definir qual sera a cor da comida. Como pretendemos fazer um jogo multiplayer, não faz sentido termos comidas coloridas, já que estas serão dos jogadores, sendo assim podemos criar um módulo chamado food
e adicionar a constante const FOOD_COLOR: Color = Color::rgb(1.0, 1.0, 1.0)
. Próximo passo é criamos um componente chamado Food
para representar a comida:
// food.rs
#[derive(Component)]
pub struct Food;
Próximo passo é criarmos um sistema que gera uma comida em um local aleatório da grade. Como este sistema utiliza aleatoriedade, podemos utilizar uma biblioteca de property testing
semelhante a proptest do python, a propcheck do Elixir e a quickcheck do Haskell, chamada proptest
para gerar centenas de cenários de teste. Para isso, adicionamos proptest = "1.0.0"
como uma dev-dependencies
no Cargo.toml e para utilizarmos basta utilizar a macro proptest!
e determinar os valores a serem executados (ou quantidade de cenários) como argumento da função de teste como em _execution in 0u32..1000
:
#[cfg(test)]
mod test {
use crate::components::Position;
use super::*;
use proptest::prelude::*;
proptest!{
#[test]
fn spawns_food_inplace(_execution in 0u32..1000) {
// Setup app
let mut app = App::new();
// Add startup system
app.add_startup_system(spawn_system);
// Run systems
app.update();
let mut query = app.world.query_filtered::<&Position, With<Food>>();
assert_eq!(query.iter(&app.world).count(), 1);
query.iter(&app.world).for_each(|position| {
let x = position.x;
let y = position.y;
assert!(x >= 0 && x as i32 <= (GRID_WIDTH -1) as i32);
assert!(y >= 0 && y as i32 <= (GRID_HEIGHT -1) as i32);
})
}
}
}
A vantagem de um proptest é que ele permite executar diversos cenários e podemos definir regras de limite para falha, executando centenas de cenários em poucos segundos. Para este teste passar, precisamos implementar a função spawn_system
para o módulo food
:
// food.rs
pub fn spawn_system(mut commands: Commands) {
commands
.spawn_bundle(SpriteBundle {
sprite: Sprite {
color: FOOD_COLOR,
..default()
},
..default()
})
.insert(Food)
.insert(Position {
x: (random::<u16>() % GRID_WIDTH) as i16,
y: (random::<u16>() % GRID_HEIGHT) as i16,
})
.insert(Size::square(0.8));
}
O próximo passo é adicionar o sistema a App
na função main
, porém este sistema tem uma pegadinha. Como não queremos que o sistema gere uma nova comdia para cada frame, precisamos definir um tempo de intervalo para as comidas serem geradas. Como este cenário de executar uma função somente a cada x segundos é muito comum no desenvolvimento de jogos a Bevy nos disponibiliza a struct FixedTimestep
que nos permite definir um passo (step
) em segundos, que será usada com a função with_run_criteria
:
// main.rs
pub mod food;
fn main() {
App::new()
.insert_resource(WindowDescriptor {
title: "Snake Game".to_string(),
width: 500.0,
height: 500.0,
..default()
}) // <--
.add_startup_system(setup_camera)
.add_startup_system(snake::spawn_system)
.add_plugins(DefaultPlugins)
.add_system(snake::movement_system)
.add_system_set(
SystemSet::new()
.with_run_criteria(FixedTimestep::step(1.0)) // <-- Pegadinha
.with_system(food::spawn_system), // <-- Sistema
) // <--
.add_system_set_to_stage(
CoreStage::PostUpdate,
SystemSet::new()
.with_system(grid::position_translation)
.with_system(grid::size_scaling),
)
.run();
}
Próximo passo será melhorar o movimento da cabeça da cobra, tornando ele mais lento e cadenciado.
Melhorando a Cadência do Movimento
O atual movimento da cobra está ligado aos comandos do teclado diferentemente do snale game que a cobra se movimenta independente dos comandos do teclado e a cada x segundos, em vez de a cada frame. Para podermos fazer com que a cobra se movimente independente dos comandos do teclado, precisamos de uma forma de armazenar a direção que ela está se movimentando, além de evitar que a cobra vá para a direção oposta. Assim, precisamos criar o enum que armazena a direção e criar uma função que indica a direção oposta.
// components.rs
#[test]
fn opposite_direction() {
assert_eq!(Direction::Up.opposite(), Direction::Down);
assert_eq!(Direction::Down.opposite(), Direction::Up);
assert_eq!(Direction::Right.opposite(), Direction::Left);
assert_eq!(Direction::Left.opposite(), Direction::Right);
}
// ...
#[derive(PartialEq, Debug, Copy, Clone)]
pub enum Direction {
Left,
Up,
Right,
Down,
}
impl Direction {
pub fn opposite(self) -> Self {
match self {
Self::Left => Self::Right,
Self::Right => Self::Left,
Self::Up => Self::Down,
Self::Down => Self::Up,
}
}
}
Em uma etapa inicial do desenvolvimento, eu adicionaria Direction como um componente na entidade cobra, porém, a medida que a cobra ficar maior, vai ser difícil sincronizar a direção de cada elemento. Sabendo disso, vamos fazer um pouco de overengineering, e colocar Direction
como elemento do componente snake::Head
. Além disso, definimos a direção padrão como Direction::Up
utilizando a trait Default
:
// snake.rs
#[derive(Component)]
pub struct Head {
direction: Direction
}
impl Default for Head {
fn default() -> Self {
Self { direction: Direction::Up }
}
}
pub fn spawn_system(mut commands: Commands) {
commands
.spawn_bundle(SpriteBundle {
sprite: Sprite {
color: SNAKE_HEAD_COLOR,
..default()
},
transform: Transform {
scale: Vec3::new(10.0, 10.0, 10.0),
..default()
},
..default()
})
.insert(Head::default() ) // <--
.insert(Position { x: 3, y: 3 })
.insert(Size::square(0.8));
}
Separando o movimento em duas etapas
Agora que nossa cobra possui uma direção armazenada na cabeça podemos mudar seu sistema de movimento para que seja executado a cada 0.15 segundos, fazemos isso da mesma forma que fizemos com o sistema de geração de comidas:
fn main() {
App::new()
// ...
.add_system_set(
SystemSet::new()
.with_run_criteria(FixedTimestep::step(1.0))
.with_system(food::spawn_system),
)
.add_system_set(
SystemSet::new()
.with_run_criteria(FixedTimestep::step(0.150)) // <--
.with_system(snake::movement_system), // <--
)
.run();
}
Nosso sistema de movimento está atrelado à translação da cobra em uma posição para cada tecla que apertarmos e agora sabemos que o sistema de movimento deve ser independente do sistema de direção, que vamos chamar de snake::movement_input_system
. Então precisamos que o sistema de input/direção aconteça antes do sistema de movimento, e, ainda, precisamos garantir que o sistema de movimento aconteça a cada 0.15
segundos. Para solucionar este problema, vamos adicionar uma função especial para sistemas before
. Para utilizar está função, precisamos adicionar o sistema de input que vamos criar e indicar que ele deve ocorrer antes do sistema de movimento com .add_system(snake::movement_input_system.before(snake::movement_system))
, adicione ao App::new()
na função main. Agora vamos para o sistema movement_input_system
.
Primeira coisa que devemos fazer é alterar os testes para considerar que o snake::movement_system
recebe uma query com Position
e snake::Head
, além de considerar que o movimento é feito com base no enum Direction
contido dentro de snake::Head
:
// snake.rs
#[test]
fn snake_starts_moviment_up() { // <-- novo teste
// Setup app
let mut app = App::new();
// Add startup system
app.add_startup_system(spawn_system);
// Run systems
app.update();
let mut query = app.world.query::<&Head>();
let head = query.iter(&app.world).next().unwrap();
assert_eq!(head.direction, Direction::Up);
}
#[test]
fn snake_head_has_moved_up() {
// Setup
let mut app = App::new();
let default_position = Position { x: 3, y: 4 };
// Add systems
app.add_startup_system(spawn_system)
.add_system(movement_system)
.add_system(movement_input_system.before(movement_system)); // <--
// Add input resource
let mut input = Input::<KeyCode>::default();
input.press(KeyCode::W);
app.insert_resource(input);
// Run systems
app.update();
let mut query = app.world.query::<(&Head, &Position)>();
query.iter(&app.world).for_each(|(head, position)| {
assert_eq!(&default_position, position);
assert_eq!(head.direction, Direction::Up); // <-- novo assert
})
}
#[test]
fn snake_head_moves_up_and_right() {
// Setup
let mut app = App::new();
let up_position = Position { x: 3, y: 4 };
// Add systems
app.add_startup_system(spawn_system)
.add_system(movement_system)
.add_system(movement_input_system.before(movement_system)); // <--
// Move Up
let mut input = Input::<KeyCode>::default();
input.press(KeyCode::W);
app.insert_resource(input);
app.update();
let mut query = app.world.query::<(&Head, &Position)>();
query.iter(&app.world).for_each(|(_head, position)| {
assert_eq!(position, &up_position);
});
let up_right_position = Position { x: 4, y: 4 };
// Move Right
let mut input = Input::<KeyCode>::default();
input.press(KeyCode::D);
app.insert_resource(input);
app.update();
let mut query = app.world.query::<(&Head, &Position)>();
query.iter(&app.world).for_each(|(head, position)| {
assert_eq!(&up_right_position, position);
assert_eq!(head.direction, Direction::Right);
})
}
#[test]
fn snake_head_moves_down_and_left() {
// Setup
let mut app = App::new();
let down_left_position = Position { x: 2, y: 2 };
// Add systems
app.add_startup_system(spawn_system)
.add_system(movement_system)
.add_system(movement_input_system.before(movement_system)); // <--
// Move Left
let mut input = Input::<KeyCode>::default();
input.press(KeyCode::A);
app.insert_resource(input);
app.update();
// Move down
let mut input = Input::<KeyCode>::default();
input.press(KeyCode::S);
app.insert_resource(input);
app.update();
// Assert
let mut query = app.world.query::<(&Head, &Position)>();
query.iter(&app.world).for_each(|(head, position)| {
assert_eq!(&down_left_position, position);
assert_eq!(head.direction, Direction::Down);
})
}
#[test]
fn snake_cannot_start_moving_down() { // <-- novo teste
// Setup
let mut app = App::new();
let down_left_position = Position { x: 3, y: 4 };
// Add systems
app.add_startup_system(spawn_system)
.add_system(movement_system)
.add_system(movement_input_system.before(movement_system));
// Move down
let mut input = Input::<KeyCode>::default();
input.press(KeyCode::S);
app.insert_resource(input);
app.update();
// Assert
let mut query = app.world.query::<(&Head, &Position)>();
query.iter(&app.world).for_each(|(_head, position)| {
assert_eq!(&down_left_position, position);
})
}
Novos testes:
snake_starts_moviment_up
que checa se a cobra inicia seu movimento para cima.snake_cannot_start_moving_down
checa que não é possível se movimentar na direção oposta.
Com estes testes sabemos que o movement system agora deve receber uma query com a posição mutável e com a Head
para obtermos a direção. Com base na direção, movemos a posição da cabeça:
pub fn movement_system(mut heads: Query<(&mut Position, &Head)>) {
if let Some((mut pos, head)) = heads.iter_mut().next() {
match &head.direction {
Direction::Left => {
pos.x -= 1;
}
Direction::Right => {
pos.x += 1;
}
Direction::Up => {
pos.y += 1;
}
Direction::Down => {
pos.y -= 1;
}
};
}
}
Já o movement_input_system
recebe uma leitura de teclado (KeyCode
) e muda a direção com base nesta leitura do teclado e evita mudanças na direção oposta:
pub fn movement_input_system(
keyboard_input: Res<Input<KeyCode>>,
mut heads: Query<&mut Head>) {
if let Some(mut head) = heads.iter_mut().next() {
let dir: Direction = if keyboard_input.pressed(KeyCode::A) {
Direction::Left
} else if keyboard_input.pressed(KeyCode::S) {
Direction::Down
} else if keyboard_input.pressed(KeyCode::W) {
Direction::Up
} else if keyboard_input.pressed(KeyCode::D) {
Direction::Right
} else {
head.direction
};
if dir != head.direction.opposite() {
head.direction = dir;
}
}
}
Ao executarmos cargo run
podemos ver a cobra se movimentando sozinha e obedecendo o sistema de direção. Próximo passo é adicionarmos o rabo.
Adicionando um Rabo a Cobra
O rabo da cobra é uma parte um pouco mais complexa, pois para cada segmento é preciso saber o próximo segmento e para onde cada segmento esta se movendo. Assim, a forma mais simples de resolver este problema é adicionando todos os segmentos do rabo da cobra em um Vec
e manter eles em um recurso ordenado em vez de entidades e componentes simples. Essa mudança nos garante que quando atualizarmos a posição de um segmento, atualizamos seu valor com relação ao segmento anterior. Isso pode ser feito iterando sobre todos os segmentos em pares com a função windows
que nos permite acessar o elemento anterior e o atual da lista. Outro elemento importante é que precisamos definir uma cor para o rabo da cobra, define que seria um tom de rosa com:
// snake.rs
const SNAKE_SEGMENT_COLOR: Color = Color::rgb(0.8, 0.0, 0.8);
Agora que definimos uma cor, podemos começar a pensar no primeiro teste. O teste mais simples para este caso parece ser verificar se a entidade relacionada aos segmentos da cobra possui 2 segmento inicialmente:
// snake.rs
#[test]
fn entity_snake_has_two_segments() {
// Setup app
let mut app = App::new();
// Adicionar sistema de spawn e recurso com segmentos
app
.insert_resource(Segments::default())
.add_startup_system(spawn_system);
// Executar sistema
app.update();
// Buscar todas entidades com componente `Segment`
let mut query = app.world.query_filtered::<Entity, With<Segment>>();
assert_eq!(query.iter(&app.world).count(), 2);
}
Executando esse teste vemos que precisamos adicionar o recurso Segments
ao nosso código, um vetor de entidades como falamos anteriormente:
// snake.rs
#[derive(Default, Deref, DerefMut)]
pub struct Segments(Vec<Entity>);
Com isso, precisamos reescrever nosso snake::spawn_system
para utilizar Segments
. Como é um startup system, será executado para iniciar entidades do jogo:
pub fn spawn_system(mut commands: Commands, mut segments: ResMut<Segments>) {
*segments = Segments(vec![
commands
.spawn_bundle(SpriteBundle {
sprite: Sprite {
color: SNAKE_HEAD_COLOR,
..default()
},
transform: Transform {
scale: Vec3::new(10.0, 10.0, 10.0),
..default()
},
..default()
})
.insert(Head::default())
.insert(Segment)
.insert(Position { x: 3, y: 3 })
.insert(Size::square(0.8))
.id(),
]);
}
Neste novo spawn_system
recebemos o recurso Segments
como um recurso mutável mut segments: ResMut<Segments>
e definimos segments
através de uma dereferência mutável, #[derive(.., DerefMut)]
, reasignando o Segment::default()
, que equivale a um vetor vazio, ao nosso recem adicionado componente. Outra mudança que precisamos fazer é adicionar o recurso Segments
ao nosso startup do app na main.rs
e em todos testes de snake.rs
:
// main.rs
fn main() {
App::new()
.insert_resource(WindowDescriptor {
title: "Snake Game".to_string(),
width: 500.0,
height: 500.0,
..default()
})
.insert_resource(snake::Segments::default()) // <-- adicionar
.add_startup_system(snake::spawn_system)
// ...
.run();
}
// snake.rs
#[test]
fn entity_has_snake_head() {
let mut app = App::new();
app
.insert_resource(Segments::default()) // <-- adicionar em todos testes
.add_startup_system(spawn_system);
app.update();
let mut query = app.world.query_filtered::<Entity, With<Head>>();
assert_eq!(query.iter(&app.world).count(), 1);
}
Agora, executando nossos testes percebemos que o novo teste, entity_snake_has_two_segments
, falha por possuir somente uma entidade. Para isso, precisamos criar um novo sistema que adiciona um novo Segment
em posição específica e retorna uma Entity
id para adicionarmos no recurso Segments
. Este sistema que cria um novo segmento é bastante semelhante ao segmento que instancia a cobra em si, spawn_system
, sua maior diferença é o fato de que passamos uma posição, Position
, como argumento para o segmento e que o tamanho do quadrado é menor, com coloração diferente.
pub fn spawn_segment_system(mut commands: Commands, position: Position) -> Entity {
commands
.spawn_bundle(SpriteBundle {
sprite: Sprite {
color: SNAKE_SEGMENT_COLOR,
..default()
},
transform: Transform {
scale: Vec3::new(10.0, 10.0, 10.0),
..default()
},
..default()
})
.insert(Segment)
.insert(position)
.insert(Size::square(0.65))
.id()
}
Com este novo sistema, podemos adicionar um segmento extra no nosso snake::spawn_system
, que armazenará os segmentos cabeça e o recem criado como entidades em Segments
:
pub fn spawn_system(mut commands: Commands, mut segments: ResMut<Segments>) {
*segments = Segments(vec![
commands
.spawn_bundle(SpriteBundle {
sprite: Sprite {
color: SNAKE_HEAD_COLOR,
..default()
},
transform: Transform {
scale: Vec3::new(10.0, 10.0, 10.0),
..default()
},
..default()
})
.insert(Head::default())
.insert(Segment)
.insert(Position { x: 3, y: 3 })
.insert(Size::square(0.8))
.id(),
spawn_segment_system(commands, Position { x: 3, y: 2 }), // <-- novo segmento
]);
}
Ao executarmos os teste agora, vemos que todos passam e podemos focar no próximo passo, fazer os segmentos se moverem seguindo a cabeça.
Fazendo o rabo seguir a cabeça
Anteriormente já falamos que os segmentos da cobra estão armazenados em um Vec
e podemos iterar sobre pares destes elementos utilizando a função windows
, agora nos falta entender como criar um teste para verificar se um segmento assume a posição de seu antecessor ao andar, mantendo a direção de movimento. Assim, nosso novo teste considera que a cobra é inicializada na posição x = 3
e y = 3
, e seu segmento extra em x = 3
e y = 2
. A primeira parte do teste define as novas posições da cabeça, Head
, e do segment, Segment
, com let new_position_head_right = Position { x: 4, y: 3 };
e let new_position_segment_right = Position { x: 3, y: 3 };
, ou seja, Head
se move para x = 4
e y = 3
e segment para x = 3
e y = 3
ao apertarmos a tecla D
e executarmos um novo update:
#[test]
fn snake_segment_has_followed_head() {
// Setup
let mut app = App::new();
let new_position_head_right = Position { x: 4, y: 3 };
let new_position_segment_right = Position { x: 3, y: 3 };
// Adiciona os systemas
app.insert_resource(Segments::default())
.add_startup_system(spawn_system)
.add_system(movement_system)
.add_system(movement_input_system.before(movement_system));
// adiciona resource apertando a tecla D, movimento para direita
let mut input = Input::<KeyCode>::default();
input.press(KeyCode::D);
app.insert_resource(input);
// executa sistemas
app.update();
let mut query = app.world.query::<(&Head, &Position)>();
query.iter(&app.world).for_each(|(head, position)| {
// garante que nova posição da cabeçá é esperada:
assert_eq!(&new_position_head_right, position);
// garante que nova direção é para direita:
assert_eq!(head.direction, Direction::Right);
});
let mut query = app.world.query::<(&Segment, &Position, Without<Head>)>();
query.iter(&app.world).for_each(|(_segment, position, _)| {
// garante que nova posição do segmento é esperada:
assert_eq!(&new_position_segment_right, position);
});
// ...
}
Por descargo de consciência podemos adicionar mais uma parte ao teste que muda a direção de movimento da cobra para cima:
#[test]
fn snake_segment_has_followed_head() {
// Setup
let mut app = App::new();
let new_position_head_right = Position { x: 4, y: 3 };
let new_position_segment_right = Position { x: 3, y: 3 };
// Adiciona os systemas
app.insert_resource(Segments::default())
.add_startup_system(spawn_system)
.add_system(movement_system)
.add_system(movement_input_system.before(movement_system));
// adiciona resource apertando a tecla D, movimento para direita
let mut input = Input::<KeyCode>::default();
input.press(KeyCode::D);
app.insert_resource(input);
// executa sistemas
app.update();
let mut query = app.world.query::<(&Head, &Position)>();
query.iter(&app.world).for_each(|(head, position)| {
// garante que nova posição da cabeça é esperada:
assert_eq!(&new_position_head_right, position);
// garante que nova direção é para direita:
assert_eq!(head.direction, Direction::Right);
});
let mut query = app.world.query::<(&Segment, &Position, Without<Head>)>();
query.iter(&app.world).for_each(|(_segment, position, _)| {
// garante que nova posição do segmento é esperada:
assert_eq!(&new_position_segment_right, position);
});
// NOVAS POSIÇÕES ESPERADAS
let new_position_head_up = Position { x: 4, y: 4 }; // <--
let new_position_segment_up = Position { x: 4, y: 3 }; // <--
// adiciona resource apertando a tecla W, movimento para cima
let mut input = Input::<KeyCode>::default();
input.press(KeyCode::W); // <--
app.insert_resource(input);
// executa sistemas de novo
app.update();
let mut query = app.world.query::<(&Head, &Position)>();
query.iter(&app.world).for_each(|(head, position)| {
// garante que nova posição da cabeça é esperada:
assert_eq!(&new_position_head_up, position);
// garante que nova direção da cabeça é esperada:
assert_eq!(head.direction, Direction::Up);
});
let mut query = app.world.query::<(&Segment, &Position, Without<Head>)>();
query.iter(&app.world).for_each(|(_segment, position, _)| {
// garante que nova posição do segmento é esperada:
assert_eq!(&new_position_segment_up, position);
})
}
Ao exercutarmos este código veremos que o assert assert_eq!(&new_position_segment_right, position);
falha indicando que o segmento não se moveu, mas a cabeça se moveu. Como estamos falando de movimento, sabemos que precisamos alterar o sistema movement_system
para incluir uma iteração sobre os elementos de Segments
. Assim a primeira alteração é adicionar o recurso segments
nos argumentos do sistema, segments: ResMut<Segments>
. Depois disso vamos precisar extrair duas queries diferentes para posição, Position
, e cabeça, Head
, já que agora nem todas posições terão cabeças, mas todas posições terão Segment
, fazemos isso com as queries mut heads: Query<(Entity, &Head)>
e mut positions: Query<(Entity, &Segment, &mut Position)>
. Precisamos de Segment
, pois Food
também possui Position
e queremos evitar iterar por elementos desnecessarios em um ECS.
O código que vamos adicionar ao movement_system
é simples, basicamente iteramos por segments
dois a dois elementos por vez e adicionamos a posição do elementos anterior, entity[0]
, na posição do elemento posterior, entity[1]
. Algo como:
(*segments).windows(2).for_each(|entity| {
if let Ok((_, _segment, mut position)) = positions.get_mut(entity[1]) {
if let Ok((_, _, mut new_position)) = positions.get_mut(entity[0]) {
*position = new_position.clone();
}
};
});
Infelizmente, esse código em particular não compila devido ao fato de acessarmos positions
como referência mutável duas vezes, inverter uma referência imutável e outra mutável também não funcionaria devida a uma regra do borrow checker do Rust que impede que um mesmo valor seja acesso como mutável e imutável no mesmo bloco. O mesmo acontece com duas referências diferentes mutáveis no mesmo bloco, o compilador não teria garantia que ambas não mutaria o mesmo valor ao mesmo tempo. Uma solução para este problema é adicionar um clone de positions
, mas podemos fazer este clone ser mais inteligente, transformando ele em um HashMap
na qual a chave é a entidade e a position é o valor, para depois acessarmos este clone de positions
de forma imutável:
pub fn movement_system(
segments: ResMut<Segments>,
mut heads: Query<(Entity, &Head)>,
mut positions: Query<(Entity, &Segment, &mut Position)>,
) {
// Criar um hashmap clonado de positions com `Entity => Position`
let positions_clone: HashMap<Entity, Position> = positions
.iter()
.map(|(entity, _segment, position)| (entity, position.clone()))
.collect();
// Acessar a cabeça (única existente por hora)
if let Some((id, head)) = heads.iter_mut().next() {
// Iterar sobre segments 2 a 2
(*segments).windows(2).for_each(|entity| {
// Acessar a posição da `entity[1]` em positions
if let Ok((_, _segment, mut position)) = positions.get_mut(entity[1]) {
// Acessar a posição da `entity[0]` em positions_clone
if let Some(new_position) = positions_clone.get(&entity[0]) {
// Substituir position por new_position
*position = new_position.clone();
}
};
});
// mesmo código de antes para mover a cabeça
let _ = positions.get_mut(id).map(|(_, _segment, mut pos)| {
match &head.direction {
Direction::Left => {
pos.x -= 1;
}
Direction::Right => {
pos.x += 1;
}
Direction::Up => {
pos.y += 1;
}
Direction::Down => {
pos.y -= 1;
}
};
});
}
}
Agora sim, nosso teste passa e se executarmos cargo run
vemos que o segmento rosa sempre segue a cabeça. Próximo passo é entender como criar um sistema para expandir a cobra ao comer.
Alimentando a cobra
Faz algum tempo que nossa cobra está rodeada de comida, mas não pode comer, por isso chegou a hora de elaborarmos um teste que vai garatir que nossa cobra possa comer e que ela vai crescer ao comer. Nosso teste vai partir de um setup um pouco mais complexo, pois agora precisamos registrar um evento de crescimento, GrowthEvenet
, precisamos registrar a última posição do vetor de segmentos, LastTailPosition
, o nosso antigo sistema de spawn de comida, crate::food::spawn_system
, e um system set organizado o que acontece primeiro. Algo como o seguinte bloco:
app.insert_resource(Segments::default())
.insert_resource(LastTailPosition::default())
.add_event::<GrowthEvent>()
.add_startup_system(spawn_system)
.add_system(crate::food::spawn_system)
.add_system_set(
SystemSet::new()
.with_system(movement_system)
.with_system(eating_system.after(movement_system))
.with_system(growth_system.after(eating_system))
);
Depois disso teremos dois updates, primeiro update cria o contexto de mundo e nos permite verificar que uma comida foi spawnada e que a cobra possui dois segmentos:
app.update();
let mut query = app.world.query::<(&Segment, &Position)>();
assert_eq!(query.iter(&app.world).count(), 2);
let mut query = app.world.query::<(&Food, &Position)>();
assert_eq!(query.iter(&app.world).count(), 1);
Por último, executamos mais uma vez o update e verificamos se a cobra possui 3 segmentos agora:
app.update();
let mut query = app.world.query::<(&Segment, &Position)>();
assert_eq!(query.iter(&app.world).count(), 3);
Infelizmente, um teste somento com isso não nos garantiria sucesso, já que o crate::food::spawn_system
gera uma posição aleatória dentro do grid, para isso precisamos modificar a função crate::food::spawn_system
para termos controle da posição que a comida vai surgir. Fazemos isso adicionado uma macro de compilação exclusiva de teste, cfg!
:
// food.rs
#[allow(clippy::cast_possible_wrap)]
pub fn spawn_system(mut commands: Commands) {
commands
.spawn_bundle(SpriteBundle {
sprite: Sprite {
color: FOOD_COLOR,
..default()
},
..default()
})
.insert(Food)
.insert(Position {
x: if cfg!(test) { 3 } else { (random::<u16>() % GRID_WIDTH) as i16 }, // <--
y: if cfg!(test) { 5 } else { (random::<u16>() % GRID_HEIGHT) as i16 }, // <--
})
.insert(Size::square(0.65));
}
Agora sim, nosso teste poderá fazer sentido ao compilar:
// snake.rs
#[test]
fn snake_grows_when_eating() {
// Setup
let mut app = App::new();
// sistemas
app.insert_resource(Segments::default())
.insert_resource(LastTailPosition::default())
.add_event::<GrowthEvent>()
.add_startup_system(spawn_system)
.add_system(crate::food::spawn_system)
.add_system_set(
SystemSet::new()
.with_system(movement_system)
.with_system(eating_system.after(movement_system))
.with_system(growth_system.after(eating_system))
);
// update de configuração
app.update();
let mut query = app.world.query::<(&Segment, &Position)>();
assert_eq!(query.iter(&app.world).count(), 2);
let mut query = app.world.query::<(&Food, &Position)>();
assert_eq!(query.iter(&app.world).count(), 1);
// update de execução
app.update();
let mut query = app.world.query::<(&Segment, &Position)>();
assert_eq!(query.iter(&app.world).count(), 3);
}
Pronto, agora podemos ir jantar, mas, infelizmente, nossa cobra ainda não. Para isso, começamos com o snake::eating_system
. O objetivo de snake::eating_system
é simples, iterar sobre todas as entidades com componente comida, Food
, e ver se a posição delas, Position
, é igual a Position
das entidades com Head
. Caso, a firmação anterior seja verdade, removemos, despawn
, a entidade Food
e lançamos no sistema um evento para crescer a cobra, GrowthEvent
, na sua última posição, pub struct LastTailPosition(Option<Position>)
. Um detalhe importante, é que para publicar um evento, EventWriter
, precisamos registrar esse evento no App
, com add_event::<GrowthEvent>()
, o mesmo vale para lermos os eventos, EventReader
.
pub struct GrowthEvent;
#[derive(Default)]
pub struct LastTailPosition(Option<Position>);
pub fn eating_system(
mut commands: Commands,
mut growth_writer: EventWriter<GrowthEvent>,
food_positions: Query<(Entity, &Position), With<Food>>,
head_positions: Query<&Position, With<Head>>,
) {
for head_pos in head_positions.iter() {
for (ent, food_pos) in food_positions.iter() {
if food_pos == head_pos {
commands.entity(ent).despawn();
growth_writer.send(GrowthEvent);
}
}
}
}
Na função anterior, recebemos um EventWriter
do tipo GrowthEvent
, que vai publicar quaisquer eventos necessários, uma query com todas as entidades e posições de comidas, Query<(Entity, &Position), With<Food>>
e uma query com a posição das cabeças, no caso, apenas uma, Query<&Position, With<Head>>
. Iteramos por tudo e checamos se as posições são iguais para então removermos a entidade associada a comida e publicarmos um evento de crescimento. Próximo passo é lermos o evento com EventReader<GrowthEvent>
e adicionarmos um clone da última posição do rabo, como um novo segmento, aos segmentos, Segments
:
pub fn growth_system(
commands: Commands,
last_tail_position: Res<LastTailPosition>,
mut segments: ResMut<Segments>,
mut growth_reader: EventReader<GrowthEvent>,
) {
if growth_reader.iter().next().is_some() {
segments.push(spawn_segment_system(commands, last_tail_position.0.clone().unwrap()));
}
}
Nosso teste está quase passando, precisamos adicionar a informação da última posição de segmentos quando nos movimentamos, senão LastTailPosition
será sempre None
e nossa cobra não crescerá. Para isso, adicionamos o recuso mutável, ResMut
, ao sistema movement_system
e no final dele, buscamos a última posição dos segmentos:
pub fn movement_system(
segments: ResMut<Segments>,
mut last_tail_position: ResMut<LastTailPosition>,
mut heads: Query<(Entity, &Head)>,
mut positions: Query<(Entity, &Segment, &mut Position)>,
) {
let positions_clone: HashMap<Entity, Position> = positions
.iter()
.map(|(entity, _segment, position)| (entity, position.clone()))
.collect();
if let Some((id, head)) = heads.iter_mut().next() {
(*segments).windows(2).for_each(|entity| {
if let Ok((_, _segment, mut position)) = positions.get_mut(entity[1]) {
if let Some(new_position) = positions_clone.get(&entity[0]) {
*position = new_position.clone();
}
};
});
// ...
*last_tail_position = LastTailPosition(Some(positions_clone.get(segments.last().unwrap()).unwrap().clone())); // <--
}
}
Aghora sim, nosso teste passa! Sabrmos que podemos utilizar unwrap
, pois a cobra inicia seu movimento com 2 segmentos e sabemos que ambos os segmentos possuem Position
, assim, positions_clone.get
, também, nunca será None
. Possivelmente precisaremos adicionar alguns .insert_resource(LastTailPosition::default())
no setup dos testes.
No próximo capítulo vamos aprender um pouco mais sobre colisões.
Colisões
Uma parte muito importante de jogos é a definição dos critérios de perda ou derrota. No caso do snake game, há dois critérios:
- A cobra "come" um pedaço dela mesma.
- A cobra sai dos limites, ou paredes, do jogo.
Testar a cobra comendo um pedaço dela mesma é bastante complicado considerando um cenário na qual as comidas surgem de forma aleátoria, pois a cobra precisa possuir pelo menos 5 segmentos para que ocorra uma colisão da cabeça da cobra com um segmento. Neste caso, um teste de gameplay seria mais fácil e possivelmente mais valioso, porém não é algo que planejei dentro do escopo deste livro. Por outro lado, testar que a cobra sai dos limites do jogo é bastante trivial, basta definir uma direção e garantir que após n
updates, a cobra vai colidir com as paredes. Uma vez que a condição de colisão ocorreu, podemos publicar um evento de game end, e pausa o jogo com um status de jogo. Único teste que não vou escrever neste caso é o teste de colisão com a parede de baixo, mas seria igual aos outros, porém com 3 updates extras para fazer retorno e mudar a direção para baixo.
O primeiro teste consite basicamente em fazer com que a cobra se movimente para cima até ultrapassar a parede superior e ai detectamos um componente do tipo GameEndEvent::GameOver
, derivado do evento GameEndEvent
. Adicionaremos este teste em um novo módulo chamado game.rs
:
#[test]
fn game_end_event_with_game_over() {
// Setup
let mut app = App::new();
// Sistemas
app.insert_resource(Segments::default())
.insert_resource(LastTailPosition::default())
.add_event::<GameEndEvent>() // <--
.add_startup_system(snake::spawn_system)
.add_system(snake::movement_system)
.add_system(snake::movement_input_system.before(snake::movement_system))
.add_system(game_over_system.after(snake::movement_system)); // <--
// tecla para cima
let mut input = Input::<KeyCode>::default();
input.press(KeyCode::W);
app.insert_resource(input);
// executgar sistema algumas vezes
app.update(); // x: 3, y: 4
app.update(); // x: 3, y: 5
app.update(); // x: 3, y: 6
app.update(); // x: 3, y: 7
app.update(); // x: 3, y: 8
app.update(); // x: 3, y: 9
// Verificar que não há componente de game end
let mut query = app.world.query::<&GameEndEvent>();
assert_eq!(query.iter(&app.world).count(), 0);
app.update(); // x: 3, y: 10
// Verificar que há componente de game end
let mut query = app.world.query::<&GameEndEvent>();
assert_eq!(query.iter(&app.world).count(), 1);
}
Com este teste podemos começar a implementar o primeiro critério de falha, que neste caso seria y
da posição da cabça menor que zero ou maior ou igual a GRID_HEIGHT
, ou seja, head.position.y < 0 || head.position.y >= GRID_HEIGHT
. Na função snake::movement_system
, temos acesso a head.position
dentro do block que contém o match head.direction
, assim podemos adicionar a condicional de posições depois do match e publicar o evento GameEndEvent::GameOver
pelo EventWriter
que precisamos adicionar nos argumentos da função:
pub fn movement_system(
segments: ResMut<Segments>,
mut last_tail_position: ResMut<LastTailPosition>,
mut game_end_writer: EventWriter<GameEndEvent>, // <-- Adicionar EventWriter
mut heads: Query<(Entity, &Head)>,
mut positions: Query<(Entity, &Segment, &mut Position)>,
game_end: Query<&GameEndEvent>,
) {
let positions_clone: HashMap<Entity, Position> = positions
.iter()
.map(|(entity, _segment, position)| (entity, position.clone()))
.collect();
if let Some((id, head)) = heads.iter_mut().next() {
(*segments).windows(2).for_each(|entity| {
if let Ok((_, _segment, mut position)) = positions.get_mut(entity[1]) {
if let Some(new_position) = positions_clone.get(&entity[0]) {
*position = new_position.clone();
}
};
});
let _ = positions.get_mut(id).map(|(_, _segment, mut pos)| {
match &head.direction {
Direction::Left => {
pos.x -= 1;
}
Direction::Right => {
pos.x += 1;
}
Direction::Up => {
pos.y += 1;
}
Direction::Down => {
pos.y -= 1;
}
};
if pos.y < 0
|| pos.y as u16 >= GRID_HEIGHT // <-- Condicional de limites do grid
{
game_end_writer.send(GameEndEvent::GameOver); // <-- publicar evento
}
});
*last_tail_position = LastTailPosition(Some(
positions_clone
.get(segments.last().unwrap())
.unwrap()
.clone(),
));
}
}
Agora todos os testes que lidam com movement_system
falham e é preciso adicionar .add_event::<GameEndEvent>()
ao setup de sistemas, além disso, adicione a função main
. Outro elemento importante é adicionar o GameEndEvent
, que vamos adicionar no móduclo components.rs
:
// components.rs
#[derive(Component, Clone, Debug, PartialEq, Eq)]
pub enum GameEndEvent {
GameOver,
}
impl Default for GameEndEvent {
fn default() -> Self {
Self::GameOver
}
}
Perfeito, mas o teste ainda não passa, pois não temos nenhum sistema escutando pelo evento GameEndEvent
, podemos adicionar um sistema game_over_system
que adicionará o componente GameEndEvent::GameOver
que buscamos no teste. Este sistema verificará se existe algum evento do tipo GameEndEvent
, se houver cria uma entidade com GameEndEvent::GameOver
como componente e print no console "Game Over!"
;
// game.rs
pub fn game_over_system(mut commands: Commands, mut reader: EventReader<GameEndEvent>) {
if reader.iter().next().is_some() {
commands.spawn().insert(GameEndEvent::GameOver);
println!("{}", GameEndEvent::GameOver);
}
}
Para printar no console um enum podemos implementar a trait Display:
// components.rs
impl Display for GameEndEvent {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
GameEndEvent::GameOver => write!(f, "Game Over!"),
}
}
}
Agora nosso teste passa, mas quero adicionar um assert extra no nosso teste, que a posição da cobra não mudará após um game over. Fazemos isso adicionando uma verificação que a posição após o GameEndEvent::GameOver
não mudará mesmo após updates.
#[test]
fn game_end_event_with_game_over() {
// ...
let mut query = app.world.query::<&GameEndEvent>();
assert_eq!(query.iter(&app.world).count(), 1);
let mut query = app.world.query_filtered::<&Position, With<Head>>();
let position_at_gameover = query.iter(&app.world).next().unwrap();
let snake_position_after_game_over = position_at_gameover.clone();
app.update();
let mut query = app.world.query_filtered::<&Position, With<Head>>();
let position_after_gameover = query.iter(&app.world).next().unwrap();
assert_eq!(snake_position_after_game_over, position_after_gameover.clone());
}
Essa mudança é facilmente resolvida adicionando uma query que busca por um GameEndEvent
, Query<&GameEndEvent>
, e verificando se ela não está vazia em um if
:
pub fn movement_system(
segments: ResMut<Segments>,
mut last_tail_position: ResMut<LastTailPosition>,
mut game_end_writer: EventWriter<GameEndEvent>,
heads: Query<(Entity, &Head)>,
mut positions: Query<(Entity, &Segment, &mut Position)>,
game_end: Query<&GameEndEvent>, // <-- GameEndEvent Query
) {
// ...
if let Some((id, head)) = heads.iter().next() {
(*segments).windows(2).for_each(|entity| {
if let Ok((_, _segment, mut position)) = positions.get_mut(entity[1]) {
if let Some(new_position) = positions_clone.get(&entity[0]) {
*position = new_position.clone();
}
};
});
if game_end.is_empty() { // <-- if verificando se houve um evento de fimd e jogo
let _ = positions.get_mut(id).map(|(_, _segment, mut pos)| {
match &head.direction {
// ...
};
if pos.y < 0
|| pos.y as u16 >= GRID_HEIGHT
{
game_end_writer.send(GameEndEvent::GameOver);
}
});
}
// ...
}
}
Próximo teste é verificar se o mesmo acontece se movendo para esquerda e para a direita. Começamos pela esquerda:
#[test]
fn game_end_event_with_game_over_when_moving_left() {
// Setup
let mut app = App::new();
// Add systems
app.insert_resource(Segments::default())
.insert_resource(LastTailPosition::default())
.add_event::<GameEndEvent>()
.add_startup_system(snake::spawn_system)
.add_system(snake::movement_system)
.add_system(snake::movement_input_system.before(snake::movement_system))
.add_system(game_over_system.after(snake::movement_system));
// Add new input resource
let mut input = Input::<KeyCode>::default();
input.press(KeyCode::A);
app.insert_resource(input);
// Run systems again
app.update(); // x: 2, y: 3
app.update(); // x: 1, y: 3
app.update(); // x: 0, y: 3
let mut query = app.world.query::<&GameEndEvent>();
assert_eq!(query.iter(&app.world).count(), 0);
app.update(); // x: -1, y: 3
let mut query = app.world.query::<&GameEndEvent>();
assert_eq!(query.iter(&app.world).count(), 1);
}
Com isso adicionamos a verificação se head.position
não é menor que zero:
pub fn movement_system(
// ...
) {
// ...
if let Some((id, head)) = heads.iter().next() {
// ...
if game_end.is_empty() { // <-- if verificando se houve um evento de fimd e jogo
let _ = positions.get_mut(id).map(|(_, _segment, mut pos)| {
match &head.direction {
// ...
};
if pos.x < 0 // <-- Nova Verificação
|| pos.y < 0
|| pos.y as u16 >= GRID_HEIGHT
{
game_end_writer.send(GameEndEvent::GameOver);
}
});
}
// ...
}
}
Depois, repetimos o teste se movendo para direita e com uma verificação se head.position.x
maior ou igual a GRID_WIDTH
:
#[test]
fn game_end_event_with_game_over_when_moving_right() {
// Setup
let mut app = App::new();
// Add systems
app.insert_resource(Segments::default())
.insert_resource(LastTailPosition::default())
.add_event::<GameEndEvent>()
.add_startup_system(snake::spawn_system)
.add_system(snake::movement_system)
.add_system(snake::movement_input_system.before(snake::movement_system))
.add_system(game_over_system.after(snake::movement_system));
// Add new input resource
let mut input = Input::<KeyCode>::default();
input.press(KeyCode::D);
app.insert_resource(input);
// Run systems again
app.update(); // x: 4, y: 3
app.update(); // x: 5, y: 3
app.update(); // x: 6, y: 3
app.update(); // x: 7, y: 3
app.update(); // x: 8, y: 3
app.update(); // x: 9, y: 3
app.update(); // x: 10, y: 3
let mut query = app.world.query::<&GameEndEvent>();
assert_eq!(query.iter(&app.world).count(), 1);
}
pub fn movement_system(
// ...
) {
// ...
if let Some((id, head)) = heads.iter().next() {
// ...
if game_end.is_empty() {
let _ = positions.get_mut(id).map(|(_, _segment, mut pos)| {
match &head.direction {
// ...
};
if pos.x < 0
|| pos.y < 0
|| pos.x as u16 >= GRID_WIDTH // <-- Nova verificação
|| pos.y as u16 >= GRID_HEIGHT
{
game_end_writer.send(GameEndEvent::GameOver);
}
});
}
// ...
}
}
Colidindo com o rabo
Como mencionei antes, escrever um teste para este cenário é um pouco mais trabalhoso que eu gostariae acaba sendo mais fácil fazer com alguma ferramenta de testes automatizados, mas caso você queira um desafio, para escrever este teste você pode executar o sistema de spawn de segmentos (spawn_segment_system
) com posições, Position
, especificas e ao realizar um update a posição de Head
vai ser igual a posição de um elemento do rabo. Agora vamos ao código, é uma mudança muito simples em movement system, basta adicionarmos mais uma cláusula if
que checa se a posição de Head
é a mesma que qualquer posição de Segment
, infelizmente não temos uma estrutura de dados que possui todas a Positions
com Segments
identificadas, mas possuimos positions_clone
que é um HashMap<Entity, Position>
.
Para descobrirmos o valor de position que não contém Head
precisamos filtrar por todas Positions
, cuja Entity
correspondente não é igual ao id
de Head
, algo como positions_clone.iter().filter(|(k, _)| k != &&id)
. Com isso, teremos um iterável que possui todos os pares Entity, Position
que não correspondem ao conjunto Entity, Position, Head
e podemos continuar iterando somente com Positions
adicionando .map(|(_, v)| v)
, para depois verificamos se existe qualquer Position
que equivale ao par Head, Position
, utilizando o valor da variável pos
, .any(|segment_position| &*pos == segment_position)
. Adicionamos esta lógica logo após o outro if de game over e publicamos outro GameEndEvent::GameOver
:
pub fn movement_system(
segments: ResMut<Segments>,
mut last_tail_position: ResMut<LastTailPosition>,
mut game_end_writer: EventWriter<GameEndEvent>,
heads: Query<(Entity, &Head)>,
mut positions: Query<(Entity, &Segment, &mut Position)>,
game_end: Query<&GameEndEvent>,
) {
let positions_clone: HashMap<Entity, Position> = positions
.iter()
.map(|(entity, _segment, position)| (entity, position.clone()))
.collect();
if let Some((id, head)) = heads.iter().next() {
// ...
if game_end.is_empty() {
let _ = positions.get_mut(id).map(|(_, _segment, mut pos)| {
match &head.direction {
// ...
};
if pos.x < 0
|| pos.y < 0
|| pos.x as u16 >= GRID_WIDTH
|| pos.y as u16 >= GRID_HEIGHT
{
game_end_writer.send(GameEndEvent::GameOver);
}
if positions_clone.iter()
.filter(|(k, _)| k != &&id)
.map(|(_, v)| v)
.any(|segment_position| &*pos == segment_position)
{
game_end_writer.send(GameEndEvent::GameOver);
}
});
}
// ...
}
}
Agora é hora de um teste manual e voilá, "a cobra morde o rabo!". Proxima colisão que devemos impedir é a de comidas surgindo em posições já ocupadas.
Colisões de surgimento de comdias
Particularmente não sou fã dessa, pois na minha concepção uma comida deveria poder surgir embaixo da cobra, desde que não seja na cabeça, mas vale a explicação pelo exemplo. Assim, o teste que vamos escrever é bastante simples, pois vamos apenas checar se a quantidade de entidades com os componentes Food
e Position
é 1
, apesar de termos dois updates. Podemos fazer isso por conta da condição de spawn
associada a testes em food::spawn_system
, quando utilizados if cfg!(test)
com valores pré-fixados.
#[test]
fn food_only_spawns_once() {
// Setup
let mut app = App::new();
// Add systems
app.add_system(spawn_system);
// Run systems
app.update();
let mut query = app.world.query::<(&Food, &Position)>();
assert_eq!(query.iter(&app.world).count(), 1);
// Run systems
app.update();
let mut query = app.world.query::<(&Food, &Position)>();
assert_eq!(query.iter(&app.world).count(), 1)
}
A solução para este teste é bastante simples, Precisamos obter uma posição que não coincide com outra posição, fazemos isso com um iterador infinito, que procura pela primeira posição que não coincide com outra. Esse iterator pode ser feita com um Range
do tipo (0..)
(de 0
a infinito), depois criamos instâncias aleatórias de Position
e procuramos por uma Position
que não está contida em um HashSet
de Position
.
(0..)
.map(|_| Position {
x: if cfg!(test) {
3
} else {
(random::<u16>() % GRID_WIDTH) as i16
},
y: if cfg!(test) {
5
} else {
(random::<u16>() % GRID_HEIGHT) as i16
},
})
.find(|position| !positions_set.contains(position))
positions_set
é o HashSet<Position>
que falamos antes, podemos criar ele através de let positions_set: HashSet<&Position> = positions.iter().collect();
, porém Position
não implementa a trait Hash
, que é facilmente resolvível adicionando a macro Hash
ao derive
de Position
:
#[derive(Component, Clone, Debug, PartialEq, Eq, Hash)]
pub struct Position {
pub x: i16,
pub y: i16,
}
Agora, precisamos adicionar uma comida ao jogo apenas se o retorno de find é existente, Option::Some
:
pub fn spawn_system(mut commands: Commands, positions: Query<&Position>) {
let positions_set: HashSet<&Position> = positions.iter().collect();
if let Some(position) = (0..)
.map(|_| Position {
x: if cfg!(test) {
3
} else {
(random::<u16>() % GRID_WIDTH) as i16
},
y: if cfg!(test) {
5
} else {
(random::<u16>() % GRID_HEIGHT) as i16
},
})
.find(|position| !positions_set.contains(position))
{
commands
.spawn_bundle(SpriteBundle {
sprite: Sprite {
color: FOOD_COLOR,
..default()
},
..default()
})
.insert(Food)
.insert(position)
.insert(Size::square(0.65));
}
}
Para resolvermos esse problema, encapsulamos nosso iterador infinito em um if let
e em caso de Option::Some
, adicionamos uma nova comida. Porém, do jeito que escrevemos o iterador infinito vai quebrar os testes já que nunca vai encontrar uma Position
válida em testes. Assim, podemos fazer uma aproximação para o tamanho do grid, (0..(GRID_WIDTH * GRID_HEIGHT))
:
pub fn spawn_system(mut commands: Commands, positions: Query<&Position>) {
let positions_set: HashSet<&Position> = positions.iter().collect();
if let Some(position) = (0..(GRID_WIDTH * GRID_HEIGHT))
.map(|_| Position {
x: if cfg!(test) {
3
} else {
(random::<u16>() % GRID_WIDTH) as i16
},
y: if cfg!(test) {
5
} else {
(random::<u16>() % GRID_HEIGHT) as i16
},
})
.find(|position| !positions_set.contains(position))
{
commands
.spawn_bundle(SpriteBundle {
sprite: Sprite {
color: FOOD_COLOR,
..default()
},
..default()
})
.insert(Food)
.insert(position)
.insert(Size::square(0.65));
}
}
Agora sim, testes passando e comidas surgem de forma eficiente. Próximo passo antes de começar o multiplayer será atualizar o jogo para as duas versões mais novas da Bevy (0.8 e 0.9).
Migrando versões da Bevy
A equipe da Bevy fez um trabalho sensacional auxiliando equipes que usam a engine a manter seus códigos atulizados com as novas versões e guias futuros podem ser encontrados em https://bevyengine.org/learn/book/migration-guides/. Neste momento a última versão é a 0.9, portanto migraremos para versão 0.8 e depois para 0.9.
Migrando para versão 0.8
Para iniciarmos a migração basta mudarmos a versão da bevy
no Cargo.toml:
[dependencies]
bevy = { version = "0.8", features = ["dynamic"] }
rand = "0.7"
Depois ao executar um cargo check
veremos que já no arquivo main.rs
há dois erros:
OrthographicCameraBundle
é uma struct não declarada. Basta substituirOrthographicCameraBundle::new_2d()
porCamera2dBundle::default()
.FixedTimestep
não foi encontrado no módulocore
. Isso acontece poisFixedTimestep
e todas as coisas relacionadas a tempo foram movidas para o módulo time, assim mude o import parause bevy::{time::FixedTimestep, prelude::*};
.
Migração pronta!
Migrando para versão 0.9
A migração para versão 0.9 é um pouco mais trabalhosa, pois algumas inferfaces da API mudaram.
Spawn
Agora para utilizar a função Commands.spawn
precisamos enviar uma tupla com quais serão os componentes spawnados, como era feito em spawn_bundle
, ou utilizar spawn_empty
para criar uma entidade vazia. Além disso, spawn_bundle
passa a ser deprecada. Assim a mudança fica:
// Antigo
commands.spawn().insert_bundle((A, B, C));
// Novo
commands.spawn((A, B, C));
No nosso código, essa mudança reflete nos módulos game.rs
:
pub fn game_over_system(mut commands: Commands, mut reader: EventReader<GameEndEvent>) {
if reader.iter().next().is_some() {
commands.spawn_empty().insert(GameEndEvent::GameOver);
println!("{}", GameEndEvent::GameOver);
}
}
E mudamos spawn_bundle
por spawn
em food.rs
, assim como em vários lugares de snake.rs
:
pub fn spawn_system(mut commands: Commands, positions: Query<&Position>) {
let positions_set: HashSet<&Position> = positions.iter().collect();
if let Some(position) = (0..(GRID_WIDTH * GRID_HEIGHT))
.map(|_| Position {
x: if cfg!(test) {
3
} else {
(random::<u16>() % GRID_WIDTH) as i16
},
y: if cfg!(test) {
5
} else {
(random::<u16>() % GRID_HEIGHT) as i16
},
})
.find(|position| !positions_set.contains(position))
{
commands
.spawn(SpriteBundle {
sprite: Sprite {
color: FOOD_COLOR,
..default()
},
..default()
})
.insert(Food)
.insert(position)
.insert(Size::square(0.65));
}
}
Outra possível otimização é:
commands
.spawn((SpriteBundle {
sprite: Sprite {
color: FOOD_COLOR,
..default()
},
..default()
}, Food, Size::square(0.65)))
.insert(position);
Resources
Uma mudança importante para resources é que agora todo resource precisa implementar a trait Resource
através da macro derive, então no módulo snake.rs
precisamos adicionar:
#[derive(Default, Deref, DerefMut, Resource)] // <-- Resource
pub struct Segments(Vec<Entity>);
#[derive(Default, Resource)] // <-- Resource
pub struct LastTailPosition(Option<Position>);
Além disso, o jeito de adicionar WindowDescriptor
ao App
mudou, pois agora o WindowDescriptor
é parte do WindowPlugin
, que deve ser configurado em DefaultPlugins
:
fn main() {
App::new()
.add_plugins(DefaultPlugins.set(WindowPlugin {
window: WindowDescriptor {
title: "Snake Game".to_string(),
width: 500.0,
height: 500.0,
..default()
},
..default()
}))
// REMOVER
// .insert_resource(WindowDescriptor {
// title: "Snake Game".to_string(),
// width: 500.0,
// height: 500.0,
// ..default()
// })
// .add_plugins(DefaultPlugins)
.insert_resource(snake::Segments::default())
.insert_resource(snake::LastTailPosition::default())
.add_event::<GrowthEvent>()
.add_event::<GameEndEvent>()
.add_startup_system(setup_camera)
.add_startup_system(snake::spawn_system)
// ...
}
Last thing on handling Window
resource, the Window::new
function now receives an Optional raw_handle
, então nos testes em grid.rs
devemos remover let raw_window_handle = Some(RawWindowHandle::Web(WebHandle::empty()));
e modificar Window para:
let window = Window::new(
WindowId::new(),
&descriptor, 400, 400, 1., None,
None, // <--
);
Migrando para versão 0.10
Primeiro passo é utilizarmos cargo outdated -R
para identificarmos quais bibliotecas podem ser atualizadas. O resultado eh:
$ cargo outdated -R
warning: Feature dynamic of package bevy has been obsolete in version 0.10.0
Name Project Compat Latest Kind Platform
---- ------- ------ ------ ---- --------
bevy 0.9.1 --- 0.10.0 Normal ---
proptest 1.0.0 1.1.0 1.1.0 Development ---
rand 0.7.3 --- 0.8.5 Normal ---
raw-window-handle 0.4.3 --- 0.5.1 Development ---
Assim, podemos iniciar subindo as versões das bibliotecas que não são a Bevy. Iniciamos por proptest
, rand
e raw-window-handle
, que ao subirmos para as versões 1.1.0
, 0.8.5
e remover
, respectivamente. Como proptest
não trouxe nenhuma quebra de compatibilidade e estamos utilizando apenas a API mais simples de rand
, não observamos nenhum problema de compatibilidade.
Proximo passo eh seguir os passos do tutorial de migracao 0.9->0.10 e atualizarmos a versao da Bevy
para 0.10
. Ao fazermos essa atualização, a primeira grande mudança é a feature dynamic
, que agora se chama dynamic_linking
:
[dependencies]
bevy = { version = "0.10", features = ["dynamic_linking"] }
rand = "0.8.5"
Com o upgrade de versão para a 0.10
, percebemos que os módulos grid
, main
e snake
contém erros. Começaremos pelo módulo grid
que trata dos erros relacionados a Window
, j'a que agora Windows
passou a ser uma entidade e sua construção ficou simplificada. Assim, nos testes translate_position_to_window
e transform_has_correct_scale_for_window
podemos simplificar a criação de Window
com (e removendo o WindowId
do use
):
// grid.rs#test
fn transform_has_correct_scale_for_window() {
// ...
// Antiga versão
// let mut descriptor = WindowDescriptor::default();
// descriptor.height = 200.;
// descriptor.width = 200.;
// let window = Window::new(WindowId::new(), &descriptor, 200, 200, 1., None, None);
let window = Window {
resolution: WindowResolution::new(200., 200.),
..default()
};
// ...
}
fn translate_position_to_window() {
// ...
// Antiga versão
// let mut descriptor = Window::default();
// descriptor.
// descriptor.height = 400.;
// descriptor.width = 400.;
// let window = Window::new(WindowId::new(), &descriptor, 400, 400, 1., None, None);
let window = Window {
resolution: WindowResolution::new(400., 400.),
..default()
};
// ...
}
Já nas funções size_scaling
e position_translation
a mudança é bastante simples, pois Window
deixou de ser um recurso (Res
) para ser uma entidade, que devemos manusear com uma query pela window primária, primary_window: Query<&Window, With<PrimaryWindow>>
:
// grid.rs
use crate::components::{Position, Size};
use bevy::{prelude::*, window::PrimaryWindow};
// ...
#[allow(clippy::missing_panics_doc)]
#[allow(clippy::needless_pass_by_value)]
pub fn size_scaling(primary_window: Query<&Window, With<PrimaryWindow>>, mut q: Query<(&Size, &mut Transform)>) {
let window = primary_window.get_single().unwrap();
for (sprite_size, mut transform) in q.iter_mut() {
scale_sprite(transform.as_mut(), sprite_size, window);
}
}
#[allow(clippy::missing_panics_doc)]
#[allow(clippy::needless_pass_by_value)]
pub fn position_translation(primary_window: Query<&Window, With<PrimaryWindow>>, mut q: Query<(&Position, &mut Transform)>) {
let window = primary_window.get_single().unwrap();
for (pos, mut transform) in q.iter_mut() {
translate_position(transform.as_mut(), pos, window);
}
}
// ...
Existe mais uma mudança relacionada a Window
no codigo, que eh no módulo main
, ao adicionarmos o plugin de window, WindowPlugin
, pois a forma de declarar a window mudou para:
fn main() {
App::new()
.add_plugins(DefaultPlugins.set(WindowPlugin {
primary_window: Some(Window {
resolution: (1000., 1000.).into(),
title: "Snake Game".to_string(),
..default()
}),
exit_condition: ExitCondition::OnAllClosed,
close_when_requested: true,
}))
// ...
}
Agora vamos terminar o upgrade do modulo snake.rs
.
System Set
Podemos ver que o teste snake_grows_when_eating
possui um erro de compilação em add_system_set
, pois a forma como lidamos com system sets mudou bastante, já que o conceito de SystemSet
passou a ser apenas uma tupla de sistemas:
// snake.rs
#[test]
fn snake_grows_when_eating() {
// Setup
let mut app = App::new();
// Add systems
app.
// ...
.add_systems((
movement_system,
eating_system.after(movement_system),
growth_system.after(eating_system)
));
// ANTIGO
// .add_system_set(
// SystemSet::new()
// .with_system(movement_system)
// .with_system(eating_system.after(movement_system))
// .with_system(growth_system.after(eating_system)),
// );
// ...
}
No módulo main
encontramos o mesmo problema, mas lá existem sistemas com run_criteria
. A mudança nesse caso é simples:
// main.rs
use std::time::Duration;
use bevy::{prelude::*, time::{common_conditions::on_timer}, window::ExitCondition};
use components::GameEndEvent;
use snake::GrowthEvent;
// ...
fn main() {
// ...
// ANTIGO
// .add_system_set(
// SystemSet::new()
// .with_run_criteria(FixedTimestep::step(1.0))
// .with_system(food::spawn_system),
// )
// NOVO
.add_system(
food::spawn_system
.run_if(on_timer(Duration::from_secs_f32(1.0)))
)
// ANTIGO
// .add_system_set(
// SystemSet::new()
// .with_run_criteria(FixedTimestep::step(0.150))
// .with_system(snake::movement_system)
// .with_system(snake::eating_system.after(snake::movement_system))
// .with_system(snake::growth_system.after(snake::eating_system)),
// )
// NOVO
.add_system(
snake::movement_system.run_if(on_timer(Duration::from_secs_f32(0.15))))
.add_system(
snake::eating_system
.after(snake::movement_system)
.run_if(on_timer(Duration::from_secs_f32(0.15)))
)
.add_system(
snake::growth_system
.after(snake::eating_system)
.run_if(on_timer(Duration::from_secs_f32(0.15))),
)
// ...
}
Por último, temos a atualização do momento de execução dos sistemas de grid
. Na versão 0.9
executávamos eles com add_system_set_to_stage
e definindo o estágio com CoreStage::PostUpdate
. Agora basta adicionarmos .in_base_set(CoreSet::PostUpdate)
a chamado da tupla de sistemas:
// ANTIGO
// .add_system_set_to_stage(
// CoreStage::PostUpdate,
// SystemSet::new()
// .with_system(grid::position_translation)
// .with_system(grid::size_scaling),
// )
// NOVO
.add_systems(
(grid::position_translation, grid::size_scaling).in_base_set(CoreSet::PostUpdate),
)
Migrações concluídas com esse código, caso ocorra alguma incompatibilidade com uma versão nova, por favor abra uma issue ou um PR nos repositórios do github livro e codigo.
Multiplayer Local
INICIALMENTE ESCRITO NA VERSÃO 0.9 DA BEVY
Primeiro passo para entendermos um jogo multiplayer é criarmos as regras para o jogo executar corretamente no modo multiplayer, podemos fazer isso adicionando mais um player de forma local. O suporte ao multiplayer exige uma pequena refatoração, que será por onde começaremos.
Refatorando
Com a atualização do Rust para versão 1.66
, o linter do Rust sugeriu algumas novas refatorações bem simples, mas muito bem observadas no módulo grid
. A primeira é na função translate_position
e na função scale_sprite
que possuíam um casting desnecessário, podendo-se remover o as f32
das chamadas de funções window.width()
e window.height()
:
// Grid.rs
// Antes
fn scale_sprite(transform: &mut Transform, sprite_size: &Size, window: &Window) {
transform.scale = Vec3::new(
sprite_size.width / GRID_WIDTH as f32 * window.width() as f32,
sprite_size.height / GRID_HEIGHT as f32 * window.height() as f32,
1.0,
);
}
fn translate_position(transform: &mut Transform, pos: &Position, window: &Window) {
transform.translation = Vec3::new(
convert(pos.x as f32, window.width() as f32, GRID_WIDTH as f32),
convert(pos.y as f32, window.height() as f32, GRID_HEIGHT as f32),
0.0,
);
}
// Depois
fn scale_sprite(transform: &mut Transform, sprite_size: &Size, window: &Window) {
transform.scale = Vec3::new(
sprite_size.width / GRID_WIDTH as f32 * window.width(),
sprite_size.height / GRID_HEIGHT as f32 * window.height(),
1.0,
);
}
fn translate_position(transform: &mut Transform, pos: &Position, window: &Window) {
transform.translation = Vec3::new(
convert(pos.x as f32, window.width(), GRID_WIDTH as f32),
convert(pos.y as f32, window.height(), GRID_HEIGHT as f32),
0.0,
);
}
A segunda refatoração é, no mesmo módulo, a simplificação da função convert
para utilizar a função mul_add
em vez de uma multiplicação seguida por uma adição. A vantagem do uso de mul_add
é que reduz os erros por arredondamento que poderiam ser causados pelo uso de f32
:
// grid.rs
// Antes
fn convert(pos: f32, bound_window: f32, grid_side_lenght: f32) -> f32 {
let tile_size = bound_window / grid_side_lenght;
pos / grid_side_lenght * bound_window - (bound_window / 2.) + (tile_size / 2.)
}
// Depois
fn convert(pos: f32, bound_window: f32, grid_side_lenght: f32) -> f32 {
let tile_size = bound_window / grid_side_lenght;
(pos / grid_side_lenght).mul_add(bound_window, -bound_window / 2.) + (tile_size / 2.)
}
Trait
MulAdd
equivale a representar(self * a) + b
e tem como sintaxefn mul_add<A: Self, B: Self>(self, a: A, b: B) -> Self
.
Outra refatoração que fiz foi para melhorar os resultados de testes em modo release
e em Windows, utilizando a biblioteca approx = "0.5.1"
. Esta biblioteca garante uma comparação mais correta de epsilons de f32. Assim, podemos mudar todos os testes que contém comparações de f32 para utilizar o assert_relative_eq
, que recebe um epsilon de valor de erro na comparação:
// grid.rs
mod test {
use super::*;
use approx::assert_relative_eq;
fn convert_position_x_for_grid_width() {
let x = convert(4., 400., GRID_WIDTH as f32);
assert_relative_eq!(x, -20., epsilon = 0.00001);
}
#[test]
fn convert_position_y_for_grid_height() {
let x = convert(5., 400., GRID_HEIGHT as f32);
assert_relative_eq!(x, 20., epsilon = 0.00001);
}
// ...
}
Por último, vamos atualizar os testes com múltiplas chamadas de app.update()
. Fazemos isso substituindo todas as linhas por um for
com range:
// Antes
app.update(); // 3 + 1
app.update(); // 3 + 2
app.update(); // 3 + 3
app.update(); // 3 + 4
app.update(); // 3 + 5
app.update(); // 3 + 6
// Depois
for _ in 0..6 {
app.update(); // 3 + _
}
Criando Outro Player
A primeira coisa que precisamos para começar a dar suporte para multiplayer local é adicionarmos o conceito de player, ou seja, um componente chamado Player
que possui um id
com o id do player, podemos fazer isso no módulo components.rs
. Além disso, vale a pena criar uma função constante para retornar o valor de id
:
#[derive(Component, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct Player {
id: u8,
}
impl Player {
pub const fn id(&self) -> usize {
self.id as usize
}
}
Depois disso, podemos aumentar o tamanho do mapa para que duas cobras possam andar juntas sem se colidir constantemente, fazemos isso mudando o valor da janela gerada no WindowPlugin
para 1000f32
:
.add_plugins(DefaultPlugins.set(WindowPlugin {
window: WindowDescriptor {
title: "Snake Game".to_string(),
width: 1000.0,
height: 1000.0,
..default()
},
// ...
Somente esta mudança não garante mais espaço para duas cobras, assim, em grid.rs
aumentamos a quantidade de tiles de 10
para 20
, mas apenas em modo release. Definimos compilação em modo debug
com a flag de compilação #[cfg(debug_assertions)]
e modo release
com a flag de compilação #[cfg(not(debug_assertions))]
, um not
a mais:
#[cfg(debug_assertions)]
pub(crate) const GRID_WIDTH: u16 = 10;
#[cfg(not(debug_assertions))]
pub(crate) const GRID_WIDTH: u16 = 20;
#[cfg(debug_assertions)]
pub(crate) const GRID_HEIGHT: u16 = 10;
#[cfg(not(debug_assertions))]
pub(crate) const GRID_HEIGHT: u16 = 20;
Testando com uma janela maior
Essa mudança fará com que uma série de testes falhem em modo release
, necessário para windows, assim, as correções passam a ser utilizar asserts ou declarações diferentes em modo debug
e modo release
. Por exemplo:
#![allow(unused)] fn main() { // Grid.rs fn convert_position_x_for_grid_width() { let x = convert(4., 400., GRID_WIDTH as f32); #[cfg(debug_assertions)] // <-- DEBUG assert_relative_eq!(x, -20., epsilon = 0.00001); // <-- DEBUG #[cfg(not(debug_assertions))] // <-- RELEASE assert_relative_eq!(x, -110., epsilon = 0.00001); // <-- RELEASE } #[test] fn translate_position_to_window() { let position = Position { x: 2, y: 8 }; let mut default_transform = Transform::default(); let expected = Transform { #[cfg(debug_assertions)] // <-- DEBUG translation: Vec3::new(-100., 140., 0.), // <-- DEBUG #[cfg(not(debug_assertions))] // <-- RELEASE translation: Vec3::new(-150., -29.999996, 0.), // <-- RELEASE ..default() }; let mut descriptor = WindowDescriptor::default(); descriptor.height = 400.; descriptor.width = 400.; let window = Window::new(WindowId::new(), &descriptor, 400, 400, 1., None, None); translate_position(&mut default_transform, &position, &window); assert_eq!(default_transform, expected); } }
Mais exemplos podem ser encontrados no PR#14, inclusive as mudanças para os testes das próximas partes.
Utilizando Player
Primeira mudança que fiz foi algo bem simples, mas muito representativo, adicionar uma outra cor de segmentos de cobra, SNAKE_SEGMENT_COLOR
virou:
const SNAKE_HEAD_COLOR: Color = Color::rgb(0.7, 0.7, 0.7);
const SNAKE1_SEGMENT_COLOR: Color = Color::rgb(0.8, 0.0, 0.8); // <--
const SNAKE2_SEGMENT_COLOR: Color = Color::rgb(0., 0.8, 0.8); // <--
Próximo passo é adicionarmos uma referência ao vetor de segmentos da segunda cobra, podemos fazer isso modificando a struct Segments
para pub struct Segments([Vec<Entity>; 2]);
, que significa que nossa struct Segments
possui um array de tamanho 2 do tipo Vetor de Entity
, assim o id do player 1 será 0
e o id do player 2 será 1
, conforme o sistema de indexação de arrays. Essa pequena mudança quebra todo nosso código para snake.rs
, mas vou tentar explicar como ajustar as funções do modo mais lógico possível.
Sistema de Geração de Cobras (Spawn)
Agora nosso sistema de spawn exige que segments seja um Array com dois vetores de entidades, para isso podemos refatorar o código dentro de spawn_system
para ser reutilizável. Fazemos isso criando a função privada spawn_entity_with_segment
, que recebe uma referência mutável de Commands
e um u8
que corresponderá ao id do Player. É importante que o segmento da cabeça da cobra seja ciente de qual seu player_id
, para isso adicionamos essa informação nos componentes da primeira entidade, .insert(components::Player { id: player_id })
, e que player 1 e player 2 não iniciem na mesma posição, para isso utilizamos um if/else
na posição X do componente Position
, definindo a posição do player 1 em x = 3
, e do player 2 em x = GRID_WIDTH - 3
, if player_id == 0 { 3 } else { (GRID_WIDTH - 3) as i16 }
:
fn spawn_entity_with_segment(commands: &mut Commands, player_id: u8) -> Vec<Entity> {
vec![
commands
.spawn(SpriteBundle {
sprite: Sprite {
color: SNAKE_HEAD_COLOR,
..default()
},
transform: Transform {
scale: Vec3::new(10.0, 10.0, 10.0),
..default()
},
..default()
})
.insert(components::Player { id: player_id })
.insert(Head::default())
.insert(Segment)
.insert(Position {
x: if player_id == 0 {
3
} else {
(GRID_WIDTH - 3) as i16
},
y: 3,
})
.insert(Size::square(0.8))
.id(),
spawn_segment_system(
commands,
Position {
x: if player_id == 0 {
3
} else {
(GRID_WIDTH - 3) as i16
},
y: 2,
},
player_id,
),
]
}
Com essa mudança podemos atualizar a função spawn_system
para chamar a função spawn_entity_with_segment
para ambos os players:
pub fn spawn_system(mut commands: Commands, mut segments: ResMut<Segments>) {
*segments = Segments([
spawn_entity_with_segment(&mut commands, 0),
spawn_entity_with_segment(&mut commands, 1),
]);
}
Note que spawn_entity_with_segment
recebe como argumento commands: &mut Commands
e por isso, devemos modificar o tipo de commands
em spawn_segment_system
para corresponder a mesma referência mutável. É nesta função, spawn_segment_system
que vamos definir a cor dos segmentos de player que criamos anteriormente, com if player_id == 0 { SNAKE1_SEGMENT_COLOR } else { SNAKE2_SEGMENT_COLOR }
:
pub fn spawn_segment_system(commands: &mut Commands, position: Position, player_id: u8) -> Entity {
commands
.spawn(SpriteBundle {
sprite: Sprite {
color: if player_id == 0 {
SNAKE1_SEGMENT_COLOR
} else {
SNAKE2_SEGMENT_COLOR
},
..default()
},
transform: Transform {
scale: Vec3::new(10.0, 10.0, 10.0),
..default()
},
..default()
})
.insert(Segment)
.insert(position)
.insert(Size::square(0.65))
.id()
}
Sistema de Movimento
A base para o sistema de movimento é fazer com que o wasd
mova o player 1 e as setas (arrows
) movam o player 2. Agora, nosso movement_input_system
não pode se restringir a iterar sobre heads
apenas com .next()
, já que sabemos que há pelo menos dois elementos em heads
. Além disso, precisamos de uma forma de determinar de qual player estamos falando, por isso adicionamos o componente Player
na Query
de heads
, mut heads: Query<(&mut Head, &Player)>
, e iteramos sobre todos os elementos com um iter_mut().for_each((mut head, player)| { ... })
. O resto do código segue a mesma lógica de antes:
pub fn movement_input_system(
keyboard_input: Res<Input<KeyCode>>,
mut heads: Query<(&mut Head, &Player)>,
) {
heads.iter_mut().for_each(|(mut head, player)| {
let dir: Direction = if player.id() == 0 {
if keyboard_input.pressed(KeyCode::A) {
Direction::Left
} else if keyboard_input.pressed(KeyCode::S) {
Direction::Down
} else if keyboard_input.pressed(KeyCode::W) {
Direction::Up
} else if keyboard_input.pressed(KeyCode::D) {
Direction::Right
} else {
head.direction
}
} else if player.id() == 1 {
if keyboard_input.pressed(KeyCode::Left) {
Direction::Left
} else if keyboard_input.pressed(KeyCode::Down) {
Direction::Down
} else if keyboard_input.pressed(KeyCode::Up) {
Direction::Up
} else if keyboard_input.pressed(KeyCode::Right) {
Direction::Right
} else {
head.direction
}
} else {
head.direction
};
if dir != head.direction.opposite() {
head.direction = dir;
}
});
}
A mudança no movement_system
é essencialmente a mesma, agora precisamos adicionar a informação de Player
na Query
de heads
, heads: Query<(Entity, &Head, &Player)>,
, e refatorar a iteração sobre heads
para ser um for
em vez de um .next()
, considerando que agora possuímos o id
de Player
, que causa colisão com o antigo id
, que mudei para entity_id
. Note que estamos destruturando o valor de Player
em id
ao utilizarmos Player { id }
no for
loop. A única outra grande mudança é que agora para acessarmos segments
, precisamos indicar qual o player_id
do segment, fazemos isso substituindo as referências a segments
por segments[player_id]
:
pub fn movement_system(
segments: ResMut<Segments>,
mut last_tail_position: ResMut<LastTailPosition>,
mut game_end_writer: EventWriter<GameEndEvent>,
heads: Query<(Entity, &Head, &Player)>,
mut positions: Query<(Entity, &Segment, &mut Position)>,
game_end: Query<&GameEndEvent>,
) {
let positions_clone: HashMap<Entity, Position> = positions
.iter()
.map(|(entity, _segment, position)| (entity, position.clone()))
.collect();
for (entity_id, head, Player { id }) in heads.iter() {
let player_id = (*id) as usize;
(*segments[player_id]).windows(2).for_each(|entity| {
if let Ok((_, _segment, mut position)) = positions.get_mut(entity[1]) {
if let Some(new_position) = positions_clone.get(&entity[0]) {
*position = new_position.clone();
}
};
});
if game_end.is_empty() {
let _ = positions.get_mut(entity_id).map(|(_, _segment, mut pos)| {
match &head.direction {
Direction::Left => {
pos.x -= 1;
}
Direction::Right => {
pos.x += 1;
}
Direction::Up => {
pos.y += 1;
}
Direction::Down => {
pos.y -= 1;
}
};
if pos.x < 0
|| pos.y < 0
|| pos.x as u16 >= GRID_WIDTH
|| pos.y as u16 >= GRID_HEIGHT
{
game_end_writer.send(GameEndEvent::GameOver);
}
if positions_clone
.iter()
.filter(|(k, _)| k != &&entity_id)
.map(|(_, v)| v)
.any(|segment_position| &*pos == segment_position)
{
game_end_writer.send(GameEndEvent::GameOver);
}
});
}
*last_tail_position = LastTailPosition(Some(
positions_clone
.get(segments[player_id].last().unwrap())
.unwrap()
.clone(),
));
}
}
Sistema de crescimento
A única mudança realmente significativa no sistema de crescimento é que o evento GrowthEvent
precisa ter informação de qual player deve crescer, fazemos isso adicionando a informação de player ao GrowthEvent
:
pub struct GrowthEvent {
pub player_id: u8,
}
Agora, nosso eating_system
, precisa transmitir a informação do id
do Player
para o GrowthEvent
, fazemos isso adicionando Player
a query de head_positions
, head_positions: Query<(&Position, &Player), With<Head>>,
e definindo o id
destruturado como player_id
em GrowthEvent
:
pub fn eating_system(
mut commands: Commands,
mut growth_writer: EventWriter<GrowthEvent>,
food_positions: Query<(Entity, &Position), With<Food>>,
head_positions: Query<(&Position, &Player), With<Head>>,
) {
for (head_pos, Player { id }) in head_positions.iter() {
for (ent, food_pos) in food_positions.iter() {
if food_pos == head_pos {
commands.entity(ent).despawn();
growth_writer.send(GrowthEvent { player_id: *id });
}
}
}
}
A próxima mudança é fazer com que o sistema de crescimento reconheça as informações relativas a player_id
, contidas em GrowthEvent
. Como pode haver mais de um evento no EventBuffer
, precisamos iterar sobre o growth_reader
com um for_each
, em vez de um .next()
, além disso, para garantir um sistema robusto, fazemos uma checagem se o player_id
é menor que o tamanho do array segments
, já que vamos acessar o array utilizando indexação sem checagem, segments.[player_id]
. As outras mudanças simplesmente refletem as mudanças esperadas em spawn_segment_system
:
pub fn growth_system(
mut commands: Commands,
last_tail_position: Res<LastTailPosition>,
mut segments: ResMut<Segments>,
mut growth_reader: EventReader<GrowthEvent>,
) {
growth_reader.iter().for_each(|event| {
let player_id = event.player_id as usize;
if player_id < segments.len() {
segments[player_id].push(spawn_segment_system(
&mut commands,
last_tail_position.0.clone().unwrap(),
event.player_id,
));
}
});
}