Concrete Logo
Hamburger button

Como desenvolver seu próprio jogo? – parte 4

  • Blog
  • 11 de Abril de 2014
Share

Melhorias no deslocamento e zonas de pontuação

Melhorando a câmera

Esta é a quarta parte de uma série. Se você ainda não viu as primeiras partes, é só clicar neste link para a parte 1, neste para a parte 2 e neste aqui para a parte 3. Vamos começar o post de hoje com uma pergunta: o que tem de estranho no nosso projeto até o momento? Dica: Está relacionado à renderização. Última dica:

jmonkey_parte4_1

Podemos claramente ver duas coisas erradas com essa imagem. A primeira é que, pelo nosso ponto de vista, a bola está passando por cima do canto do jogador. A segunda é que o nosso jogador parece meio estranho. Esperaríamos que ele fosse mais para um retângulo propriamente dito do que um canto de um paralelepípedo. Este problema acontece porque a configuração padrão do jMonkey assume que vamos fazer um jogo 3D e, por isso, monta a câmera em um modo apropriado para a renderização 3D. Como podemos resolver isso e configurar a nossa câmera para renderizar em 2D?

Muitos tutoriais por aí, e muitos posts de fóruns vão falar para simplesmente ignorar o rootNode e a câmera principal e usar o guiNode e a câmera da interface com o usuário, já que a interface é pré-configurada para 2D. Mas não faça isso. Existem diversos motivos para termos dois nodes separados e duas câmeras separadas, desde os mais puristas de separar logicamente o que é do jogo e o que é da interface, até o mais elementar. Por exemplo, se você usar um foco de luz no seu jogo e estiver tudo no mesmo Node, essa luz irá influenciar tanto os objetos do jogo quanto os da interface.

Ao invés de fazermos isso, vamos ver então como fazemos para reconfigurar nossa câmera para que ela nos dê a visão de jogo 2D que queremos.

Para isso, criamos na class Main um método chamado initCam(), conforme o código abaixo:

Para efetivamente usarmos esse código, precisamos alterar o método simpleInitApp() e colocar a seguinte chamada na primeira linha:

Se rodarmos agora o nosso exemplo, veremos que temos algo semelhante à imagem a seguir:

jmonkey_parte4_2

Podemos ver nitidamente que os nossos jogadores agora são retângulos, mesmo ambos estando longe do centro da câmera. Vamos explicar então um pouco do que aconteceu.

A primeira chamada do método initCam() muda a forma de projeção da câmera. Por padrão, a câmera do jMonkey (e de qualquer outro engine 3D) está configurada para o modo de perspectiva. Isso é excelente para jogos e simulações 3D, pois tenta reproduzir com precisão o nosso ponto de vista. Porém, para conseguirmos fazer um jogo 2D, precisamos de um outro modo de projeção. Neste caso, um dos tipos de projeção paralela. Mais especificamente uma projeção ortográfica.

Como funciona exatamente essa projeção? Nada melhor para explicar do que usarmos uma imagem como exemplo:

jmonkey_parte4_3

A imagem no meio da tela representa a visão do nosso objeto em perspectiva. Nessa visão, podemos ver claramente três planos e temos a noção de volume do objeto. Em cada um dos três planos, vemos as projeções desse objeto em uma projeção ortogonal. Na vista lateral, o objeto se parece muito com uma das peças do clássico Tetris.

Para obtermos a visão lateral conforme essa imagem, e conforme a nossa captura de tela anterior, não é necessário apenas alinhar a câmera com um eixo. Alinhar a câmera foi o que fizemos inicialmente, e o resultado não foi o esperado. O que diferencia a visão ortogonal, da perspectiva alinhada com um eixo, é que para a visão ortogonal, consideramos o observador (no caso a câmera) a uma distância infinita de todos os objetos observados. Se simplesmente alinharmos a câmera, teremos uma visão com sensação de profundidade, dando uma visão igual ao que tínhamos nos artigos anteriores.

Uma referência interessante de um jogo que usa ambas as perspectivas é o Fez, que utiliza uma visão ortogonal durante o jogo normal, mas faz a transição entre visões utilizando uma visão em perspectiva, conforme podemos ver nesse outro exemplo na wikipedia.

A segunda parte do código calcula o frustum da câmera. O frustum da câmera nada mais é do que um tronco de pirâmide que define que tudo o que estiver dentro do volume ocupado por ele será renderizado. Exemplo:

jmonkey_parte4_4

Imagem gerada a partir do original em SVG disponível na wikipedia, no link https://en.wikipedia.org/wiki/File:ViewFrustum.svg

A definição do tronco de pirâmide utilizado recebe vários parâmetros identificando a distância mínima e máxima e as distâncias em cada eixo (relativa aos eixos locais da câmera) a serem renderizadas. Essas definições dizem precisamente o que será renderizado pela câmera e fornece dados para otimizações que o jMonkey pode fazer na hora de renderizar objetos, conforme vamos falar a seguir.

Depois de alterarmos a câmera, é recomendável chamar o método update() de modo que ela possa refazer uma série de cálculos para futuras utilizações.

Com essa configuração de visão da câmera, o jMonkey faz para nós uma otimização simples, mas muito valiosa em termos de processamento de vídeo. Por padrão, qualquer objeto anexado ao rootNode que esteja fora da área de visibilidade da câmera não será renderizado. Esse é um cálculo simples e fácil de fazer, e basicamente consiste em checar se os limites de cada objeto (o worldBound que usamos anteriormente) têm qualquer interseção com o campo de visão definido para a câmera. Se existe essa interseção, o objeto é renderizado na memória de video, mesmo que seja apenas parcialmente visível. Caso não haja nenhuma interseção, o objeto não é renderizado, economizando memória e ciclos de processamento da placa de vídeo.

Zonas de pontuação

Muito do que vamos fazer aqui é igual ao que já fizemos antes. Então, vamos passar rápido pelas modificações necessárias. Uma diferença do que fizemos antes é que vamos declarar todas os novos objetos dentro do método simpleInitApp(). Ou seja, eles serão apenas variáveis locais, e não variáveis de classe da nossa aplicação. Também vamos adicionar duas áreas de pontuação: uma atrás de cada jogador. Porém, vamos adicionar ambas as áreas como filhas de um Node vazio, que servirá apenas para agrupar e controlar essas áreas.

Todas as alterações a seguir são feitas dentro do simpleInitApp(). Primeiro, vamos declarar as nossas novas variáveis no início do método:

Depois, vamos criar um material de depuração, para facilitar a identificação de quais são os objetos invisíveis do nosso jogo:

Em seguida, criamos os objetos necessários:

Inicializamos as geometrias:

Definimos os materiais:

Anexamos os objetos à cena. Note que os nós de pontuação são filhos do nó de controle, e o nó de controle é anexado ao rootNode, pois, recapitulando, só os objetos anexados ao rootNode fazem parte da cena:

Posicionamos as zonas de pontuação:

Em todo esse código, não existe nada de grande novidade. Tudo o que fizemos aqui foi criar mais objetos e anexá-los a nossa cena. Se executarmos a nossa cena agora, teremos algo semelhante à imagem a seguir:

jmonkey_parte4_5

Ou seja, criamos novos retângulos nos cantos da tela, pintados de amarelo (para depurar), que serão as áreas de pontuação. Ou seja, quando a bola chegar em uma dessas áreas, deverá ser recolocada no centro do jogo e um dos jogadores deverá ganhar um ponto.

No momento, se deixarmos a bola chegar a uma das áreas amarelas, perceberemos que nada acontece. Para termos o comportamento desejado, precisamos criar um novo controle e anexá-lo às áreas de pontuação.

Seguindo os passos descritos no artigo anterior, vamos criar um controle chamado ControlePontuacao no pacote mygame.controle e colocar o seguinte código no início:

O código é muito parecido com o do ControleColisao. Porém, neste momento, apenas pegamos o controle de movimento da bola, e fazemos um reset da posição. Esse método ainda não existe, então, vamos criar no ControleBola o método reset, conforme abaixo:

Note que no ControlePontuacao temos um construtor privado sem parâmetros, que é obrigatório. Por isso, declaramos como privado para não ser utilizado sem querer. E declaramos também um construtor público que recebe os parâmetros que necessitamos. Neste caso, uma referência para a bola do jogo.

De volta na classe Main, no final do método simpleInitApp(), adicionamos os controles de pontuação às zonas de pontuação que criamos:

Com essas alterações, caso a bola chegue em uma área de pontuação, ela retorna ao centro do jogo mas mantendo a mesma direção de antes. Resolveremos isso logo.

Fechando a área de jogo

Já que na próxima etapa vamos fazer uma movimentação mais randômica da bola na área de jogo, precisamos antes fechar a área do jogo. Isso irá garantir que a bola não vai se perder. Fechar a área de jogo é bem parecido com adicionar a área de pontuação. Vamos adicionar objetos que vão fechar a parte superior e inferior do nosso campo de visão mas vamos adicionar a esses objetos o ControleColisao ao invés do controle ControlePontuacao.

Vamos lá:

Declaramos novas variáveis no início do método simpleInitApp()

Criamos os objetos:

Inicializamos as geometrias:

Atribuímos os materiais:

Colocamos os objetos na cena:

Reposicionamos os objetos:

Associamos os controles:

Fazendo essas alterações, teremos algo semelhante à imagem a seguir:

jmonkey_parte4_6

Ou seja, temos a nossa área de jogo totalmente fechada. Porém, a bola continua somente se deslocando lateralmente. Vamos resolver isso na próxima etapa, adicionando um deslocamento vertical na bola.

Alterando a movimentação da bola

Para a bola sair em direção aleatória, precisamos fazer algumas mudanças no ControleBola. E depois na classe Main, onde mudaremos a forma como instanciamos e associamos o controle ao nosso objeto. Primeiramente, na classe ControleBola, vamos fazer as seguintes alterações:

Uma nova variável de classe:

No construtor, substituímos a primeira linha por:

E no final do método reset(), adicionamos:

E no método simpleInitApp() da classe Main, substituímos a linha de adição do controle da bola pelo seguinte trecho de código:

Com isso, temos um método reset que coloca a bola de volta na origem, mas a cada vez que faz isso a bola se movimenta em uma direção diferente. A única novidade que temos aqui é a atribuição do vetor de direção da bola. Esse é o vetor que utilizamos no método de atualização de cada frame para decidir qual a direção que a bola segue. Vamos analisar então cada parte dessa atribuição e ver o que ela significa.

Aqui, criamos um novo vetor. Essa atribuição se dá com os parâmetros na ordem X,Y,Z. No nosso caso, como temos a câmera paralela ao plano XY, o valor de Z é sempre zero. Para cada um dos valores de X e Y, atribuímos o valor _random.nextFloat() – 0.5f. O método nextFloat() nos retorna um valor entre [0,1] e subtraindo 0.5 desse valor temos um valor final no intervalo de [-0.5, 0.5]. Isso nos dá um vetor com valores potencialmente bem diferentes no eixo X e no eixo Y. Então, como conseguimos manter uma velocidade constante da bola, independente desses valores?

O método normalize() tem um desempenho chave nesse processo, e é muito utilizado quando estamos trabalhando com vetores, em especial os de deslocamento. Os vetores possuem um módulo, que pode ser pensado no equivalente ao comprimento do vetor. O módulo do vetor é uma função dos valores das suas coordenadas. No nosso caso, depende dos valores de X, Y e Z. O que o método normalize() faz é normalizar o vetor, ou seja, recalcular os componentes do vetor de forma que o módulo do vetor seja 1. Ele retorna um vetor unitário, que mantém a mesma proporção entre os componentes do vetor original, ou seja, a mesma direção no espaço. Existem algumas explicações boas sobre o tema neste link. Então, independente dos valores que atribuímos para X e Y com o método randômico anteriormente, temos a garantia de que ao normalizar esse vetor teremos um vetor cujo o módulo final é 1.

Quando atualizamos o movimento da bola, fazemos a multiplicação do nosso vetor de direção pelo valor de velocidade da bola. Como vimos anteriormente, temos a garantia de que esse vetor é unitário, então, independente da direção da bola, a velocidade final dela (deslocamento por unidade de tempo) é constante.

Se rodarmos o nosso jogo agora, vamos reparar que a bola realmente se desloca em várias direções diferentes. Porém, se bater em alguma das bareiras horizontais ela simplesmente mudará a sua trajetória para uma trajetória vertical. E se bater em algum dos jogadores, ela muda sua trajetória para uma trajetória horizontal.

Últimas correções

Como podemos acertar a questão da reflexão? O primeiro passo é identificar o local exato onde ocorre o problema. Como ele está nas colisões da bola com os jogadores ou com os anteparos, é fácil é preciso mudar esse controle. O código do controle de colisão é extremamente simples: basta uma multiplicação simples entre dois vetores. (Apenas como observação, o método mult dos vetores faz uma multiplicação entre os elementos do vetor. O que na geometria é descrito como produto vetorial é implementado no método cross, que não é utilizado no nosso exemplo). Se a implementação do controle é trivial, temos que olhar então a atribuição dos nossos parâmetros.

Checando a criação dos controles no método simpleInitApp(), vemos que atribuímos ao eixoReflexao dos nossos controles valores parecidos com:

Conforme falamos, o método mult é uma multiplicação elemento a elemento. Nesse caso, multiplicamos cada elemento do vetor por uma constante, então, vamos ver o que estamos fazendo exatamente. Vector3f.UNIT_X é um vetor unitário na direção X, ou seja, (1, 0, 0). Multiplicado por -1, temos (-1, 0, 0). Se pararmos para validar o que significa o mult no nosso vetor, veremos que com o valor utilizado inverteremos o valor do eixo X e iremos zerar os eixos Y e Z, que é exatamente o problema que acontece quando refletimos a partir de um dos nossos jogadores. Vamos resolver o problema então, substituindo os valores que usamos como eixos de reflexão, aproveitando para manter o nosso código legível.

No final do simpleInitApp(), na seção “Associa controles” vamos fazer as seguintes mudanças:

No início da seção, adicionamos duas variáveis:

E agora atribuímos esses valores aos objetos respectivos. No caso, reflexaoHorizontal para os dois jogadores e reflexaoVertical para os dois anteparos. Ou seja, mudamos as atribuições do eixoReflexao dos nossos controles, para ficar de acordo com os códigos a seguir:

Se testarmos agora, teremos as reflexões do jeito certo. Quando bate em algum anteparo ou em um jogador, a bola é refletida em apenas um eixo, e mantém a direção no outro eixo.

Mas as velocidades ainda não estão muito boas para fazer os testes. Vamos corrigir.

No construtor do ControleBola, vamos atribuir um novo valor para a velocidade da bola:

E no analogListener da classe Main, vamos alterar o valor que utilizamos como velocidade para 250 (é o valor que multiplicamos pelo tpf). Os valores acima podem variar de acordo com a sua configuração.

Com esses valores, já podemos testar melhor o jogo e fica fácil perceber um problema que existe. Não existe uma limitação vertical para o deslocamento dos nossos jogadores. Um problema mais sutil, mas que aparece, é que de vez em quando os jogadores dão um “pulo” na velocidade de deslocamento.

Vamos explicar porque isso acontece e resolver esses problemas no próximo post.

Caso as estatísticas de renderização estejam atrapalhando a jogabilidade, basta pressionar “F5” para que elas desapareçam.

Próximos passos

Para o próximo post, vamos finalizar a movimentação dos jogadores, acertando os pulos e limitando o deslocamento para que eles não saiam da área de jogo. Vamos também começar a marcar a pontuação dos jogadores e exibi-la na tela. Como sempre, quem quiser o código finalizado dessa etapa pode baixá-lo no repositório.

Até a próxima!

E se tiver alguma dúvida até aqui, é só deixar nos comentários.