sábado, 6 de abril de 2013

Analisando o desenvolvimento de Lightbringer (Rhye's Quest)

Pra quem não sabe, Lightbringer (Rhye's Quest) é o meu novo jogo em desenvolvimento em C/C++. Portanto antes de prosseguir com a leitura deste artigo, recomendo que visite o blog oficial do jogo e saiba do que se trata: www.rhyesquest.blogspot.com.

Atualização: Agora o jogo é open-source e você pode analisar por si mesmo a arquitetura dele. Confira em: http://fernando-aires.blogspot.com/2015/01/lightbringer-rhyes-quest-agora-e-open.html

Conteúdo do artigo:

Introdução
1. SDL e XTiles: A base dos gráficos
2. Classe Object, a "pedra fundamental" da engine
3. Classe Map, um mapa do jogo
4. Classe World, o mundo inteiro está aqui
5. O editor
6. Classe Player
7. Classe Enemy
8. Introdução sobre a engine
9. Movimentação e detecção de colisões
10. Inimigos
11. Sistema de batalha
Conclusão





Introdução


Resumindo em poucas palavras, Rhye's Quest é um jogo no estilo "roguelike", um RPG baseado em turnos e com gráficos primitivos. Está sendo desenvolvido por mim mesmo há 2 anos (desde 2011, incluindo protótipos e algumas versões rudimentares que foram abandonadas ao longo desse tempo). Neste artigo eu analiso a engine do jogo para os que têm curiosidade em saber como ele foi desenvolvido. Quero ressaltar que este artigo é de um teor técnico, não é voltado aos iniciantes em programação mas sim ao pessoal que já tem alguma experiência em desenvolvimento de software em geral (especialmente em C/C++) e tem curiosidade sobre como funciona a programação de jogos.

Desenvolver um jogo está muito longe de ser uma tarefa trivial. Isto pois o processo envolve conhecimento específico de diversas disciplinas da Ciência da Computação. Preciso dizer também que o jogo está em desenvolvimento, ou seja, a engine está incompleta. Neste artigo pretendo dar uma visão detalhada na medida do possível sobre as estruturas que compõem a engine do jogo e seu funcionamento interno, pelo menos dos aspectos que já foram implementados. Não será apresentado código-fonte original do jogo pois ele não é open-source e não me permito divulgar no momento, principalmente pelo fato de ser um trabalho em andamento.

Vale lembrar que o projeto de Rhye's Quest é dividido em dois grandes pedaços: o editor e a engine. O editor é usado para desenhar os objetos e os mapas. A engine é o jogo propriamente dito, que utiliza as informações dos mapas criados no editor. Tanto o editor quanto a engine foram desenvolvidos "do zero", primeiro o editor, depois a engine.

Vamos agora discutir os principais aspectos e as principais classes do projeto.


1. SDL e XTiles: A base dos gráficos


A única "coisa pronta" inclusa no projeto de Rhye's Quest é a biblioteca SDL, que contém funcionalidade baixo-nível para permitir a utilização de conteúdo multimídia, especialmente gráficos. Tirando isso, todo o resto do sistema foi desenvolvido do zero, incluindo elementos de interface gráfica. A vantagem principal de ter usado a biblioteca SDL está no fato de que, devido a isso, o projeto é multiplataforma. Isto significa que é possível compilar Rhye's Quest para o Windows, para Linux, ou qualquer outro sistema operacional que possua alguma implementação da SDL. Em versões anteriores havia sido utilizada a biblioteca Allegro, semelhante à SDL porém mais alto-nível e não tão portável.

Além de ter usado SDL, foi aproveitada a totalidade do código de um projeto paralelo também desenvolvido por mim, chamado XTiles, que é uma espécie de encapsulamento (wrapper) da SDL que eu utilizo para desenhar gráficos primitivos muito mais facilmente. Desta forma, em todo o código do jogo não se encontra chamadas à funções da SDL, mas sim às funções de XTiles. O único lugar que utiliza diretamente a biblioteca SDL são os arquivos de implementação de XTiles. A vantagem disso é que, caso seja necessário mudar a biblioteca gráfica, digamos, trocar SDL por Allegro, basta mudar o código em XTiles para encapsular as chamadas de função da Allegro ao invés de SDL. Para mais informações sobre XTiles você pode visitar o blog dedicado a este projeto, que à propósito é open-source: www.xtiles.blogspot.com.


2. Classe Object, a "pedra fundamental" da engine


Diferente de Java, em C++ não existe uma classe Object da qual herdam todos os outros. Por isto, eu resolvi criar uma classe Object. No entanto, esta classe não tem descendentes, ou seja, ninguém herda dela. Isto porque o sistema foi concebido para ser o mais simples possível, e incorporar o conceito de herança dentro da engine aumentaria em muito a complexidade, uma vez que existem muitos tipos diferentes de objetos no jogo e ter uma classe para cada objeto herdando de Object seria desnecessário. Pode parecer falta de Engenharia de Software, porém a prioridade nesse caso foi a simplicidade.

Mas então, como o sistema sabe os tipos dos objetos sendo que só existe a classe Object mas ninguém herda dela? Simples. A classe Object encapsula uma enumeração de "tipos" de objeto, e contém um campo "tipo" que referencia um valor dessa enumeração, de forma que é possível definir ou obter o tipo de um objeto através de funções acessoras (ex: getType() e setType()). A informação de tipo do objeto é usado pela engine para determinar o que fazer ao manipular algum objeto no campo do jogo.

Além de encapsular a informação do próprio tipo, os objetos (instâncias de Object) contém outras informações importantes para o sistema, que são: o parâmetro, o índice do caractere gráfico que representa o objeto na área do jogo, as cores deste caractere, e três flags indicando certos estados do objeto - se está ativo, se está visível e se é sólido.

O parâmetro do objeto é um código de 0 a 255 cujo significado depende exclusivamente do tipo. Por exemplo, um dos tipos de objeto é PUSHABLE, que significa algo como "empurrável", "que se pode empurrar". Este tipo de objeto foi criado para implementar pedras, ou quaisquer outros objetos que podem ser empurrados dentro da área do jogo. O parâmetro deste tipo de objeto indica a resistência do objeto em ser empurrado. Um outro exemplo seria o tipo ITEM, que representa um item que o jogador pode pegar na área do jogo. O parâmetro deste tipo de objeto indica o tipo de item que ele representa. Para poucos tipos de objeto, o parâmetro é ignorado por ser desnecessário. Por exemplo, existe um (pseudo-)tipo chamado VOID, que representa um objeto nulo (um espaço vazio num mapa), cujo parâmetro não faz diferença nenhuma.

O índice do caractere gráfico do objeto é um código de 0 a 255 que indica qual é o caractere que representa esse objeto, trata-se basicamente de uma referência a um caractere no conjunto de caracteres de XTiles, para que ele possa ser desenhado na área do jogo. As cores também são armazenadas como códigos de 0 a 255 que referenciam índices dentro da paleta de cores de XTiles.

Por fim, temos as três flags que indicam certos estados do objeto: ativo, visível e sólido. Um objeto inativo é ignorado pela engine ao detectar colisões e em outras situações envolvendo outros objetos. Um objeto invisível não é desenhado pela engine no campo do jogo, e um objeto sólido serve como obstáculo ao jogador e a outros objetos móveis pois a engine proíbe que determinados tipos de objetos ultrapassem a área de objetos sólidos durante o movimento.

Uma das funcionalidades dos objetos é escrever e ler os seus dados de um arquivo, e essas informações são em formato binário. Mais adiante apresentarei mais funcionalidades da classe Object.


3. Classe Map, um mapa do jogo


A classe Map implementa cada uma das centenas de mapas do jogo, sendo que ela basicamente encapsula duas matrizes de objetos (instâncias de Object) e outras informações que cada mapa precisa armazenar. A matriz de objetos foi concebida de forma que cada linha da matriz correspondesse à coordenada Y dos personagens e cada coluna correspondesse à coordenada X. Obviamente, cada posição na matriz só pode conter um único objeto, refletindo a idéia de que dois corpos não podem ocupar o mesmo espaço.

Um detalhe importante é que as matrizes não armazenam ponteiros para objetos, mas sim os próprios objetos, de forma que não é possível ter uma posição "vazia" (com NULL), propriamente dita, de forma que todas as posições de todos os mapas contém objetos válidos. Para contornar esse problema, foi criado o tipo de objeto VOID que indica um "objeto nulo", o que para a engine significa uma posição vazia no mapa que pode ser ocupada por objetos de outros tipos e que nunca é desenhada na área do jogo independente de qualquer coisa.

Você deve ter se perguntado, por que existem DUAS matrizes de objetos? Porque cada mapa é composto de duas camadas: camada de fundo ou de baixo (BOTTOM) e camada do topo ou de cima (TOP). A camada de baixo é geralmente reservada para tipos de objetos imóveis e não-sólidos (especialmente os que representam terreno como água, solo, etc). A camada de cima é geralmente reservada para os objetos móveis, ou objetos imóveis que podem ser removidos pela engine por algum motivo. A engine trata de desenhar primeiro os objetos da camada inteira de baixo, e em seguida, desenha por cima os objetos da camada inteira de cima. Lembrando que objetos do tipo VOID e objetos invisíveis não são desenhados, ou seja, quando na camada de cima há um objeto do tipo VOID ou invisível, ao invés dele fica aparecendo o objeto da camada de baixo naquela posição, como se fosse algo transparente.

A movimentação dos objetos no mapa se dá pela substituição dos tipos dos objetos nas matrizes. Por exemplo, para mover um objeto para cima, copia-se o objeto para a linha de cima na mesma coluna, e remove-se o objeto original da posição anterior. Quando digo "remover", significa definir o tipo do objeto como VOID, pois como citado anteriormente, não é possível ter uma posição no mapa sem um objeto. Definir um objeto como sendo do tipo VOID efetivamente elimina ele do mapa. Para a engine ele deixa de existir na posição original, porém a cópia permanece na nova posição.

Agora uma curiosidade: Uma vez que os objetos no jogo não têm noção de coordenadas, o mapa é responsável por mover os objetos de uma posição a outra. Isso acontece pois as coordenadas dos objetos que estão no mapa são a linha e coluna da matriz onde eles estão inseridos. Em outras palavras, a posição de um objeto (X/Y) é mapeada aos índices (coluna/linha) de uma das matrizes do mapa. Portanto, se um objeto está na linha 10 e coluna 20 da matriz do mapa, significa que as coordenadas dele são Y=10 e X=20.

Além das matrizes de objetos, a classe Map também encapsula outras informações usadas pela engine, que são: id e nome. O id de um mapa é um código exclusivo que o identifica no mundo. O nome é o título que aparece na interface para indicar onde o jogador está num determinado momento do jogo. Uma das funcionalidades desta classe é escrever e ler mapas de um arquivo. Escrever e ler um mapa nada mais é do que iterar por todos os objetos das duas matrizes e chamar o método para escrever ou ler cada um deles no arquivo.

Para finalizar, um mapa no jogo é a área visível do jogo num determinado momento e sempre contém 475 objetos (mesmo que sejam do tipo VOID). O jogador só consegue visualizar e interagir com os objetos que estiverem no mesmo mapa que ele. O mundo do jogo é apresentado em mapas, sendo que somente um mapa é visível de cada vez. Detalhe: O mundo completo do jogo contém 1024 mapas.


4. Classe World, o mundo inteiro está aqui


A classe World apenas encapsula uma matriz de mapas. A matriz encapsulada é de tamanho 32x32, ou seja, o mundo contém 1024 mapas no total. Considerando que cada mapa contém 475 objetos, chega-se a conclusão de que o mundo inteiro do jogo sempre contém no total 486.400 objetos, sejam eles VOID ou não. É muita coisa!

Através da classe World é possível manipular o mundo inteiro do jogo como uma coisa só, e isso facilita muito o trabalho em algumas situações. Fora isso, essa classe tem métodos para escrever e ler o "mundo" de um arquivo. Estes métodos apenas iteram por todos os mapas chamando em cada um o seu método para escrever ou ler em arquivo, de forma que "escrever o mundo" num arquivo significa "escrever todos os mapas" um por um no arquivo.


5. O editor


Como citado anteriormente, o projeto de Rhye's Quest é dividido em duas grandes partes: uma é o editor e a outra é a engine. Uma vez que o mundo do jogo é enorme, para criar o conteúdo dos mapas foi necessário criar um programa especial para criar os objetos e colocá-los nos mapas nas posições iniciais. E este é programa é o editor do jogo. O editor do jogo é usado apenas por mim para a criação dos mapas e não será disponibilizado após a conclusão do projeto, somente a parte da engine será visível pelos usuários. Não vou entrar em muitos detalhes porém apresento a seguir um resumo do funcionamento deste módulo.

O editor consiste na mesma interface básica da engine, porém as informações apresentadas são diferentes. Nele é possível criar um objeto selecionando um tipo, parâmetro, caractere, cores, e definindo as flags. Utilizando um cursor é possível mover-se livremente pelos mapas do mundo e colocar os objetos criados nas suas posições iniciais. Além disso é possível definir o nome de cada mapa e definir algumas características gerais. Além disso é possível também alterar os caracteres gráficos e ver imediatamente as modificações no mapa. O editor dispões de diversas funções auxiliares para facilitar a construção dos mapas, como modo automático de desenho, modo de navegação entre mapas e substituição rápida de gráficos. Utilizando uma tecla de atalho, é possível alternar entre o editor e a engine para testar cada mapa enquanto ele é construído.

Tanto o editor quanto a engine utilizam as mesmas estruturas, e portanto as classes Object, Map e World estão presentes em ambos os módulos.

O editor permite gravar e carregar arquivos contendo o "mundo" completo do jogo em formato binário. Atualmente o arquivo gerado pelo editor contém aproximadamente 9 MB, uma vez que ainda não foi implementado nenhum tipo de compressão.


6. Classe Player


A classe Player encapsula informações referentes ao jogador, como coordenadas, estado, atributos, etc. Diferente dos objetos comuns do jogo representados pela classe Object, o jogador é representado por esta classe especial, uma vez que ela precisa conter informações muito mais específicas. Esta classe contém instâncias de outras classes auxiliares, como por exemplo Inventory, que encapsula informação de quais e quantos itens o jogador obteve durante o jogo. Veremos mais sobre a classe Player ao discutir a engine.


7. Classe Enemy


A classe Enemy encapsula informações sobre um inimigo no jogo, como coordenadas, estado, atributos, etc. Antes que você pergunte, estas informações não são duplicadas em Player e Enemy. Existe uma superclasse Fighter da qual ambas derivam estes campos. Veremos mais detalhes sobre a classe Enemy ao discutir a engine.


8. Introdução sobre a engine


A engine de Rhye's Quest é o módulo do sistema que faz tudo funcionar baseado no arquivo gerado pelo editor contendo a informação do mundo do jogo, isto é, os mapas e os objetos contidos neles nas suas posições iniciais. Pode-se dizer que a engine é o jogo propriamente dito.

Inicialmente, a engine trata de definir o estado inicial do jogo e em seguida, na maior parte do tempo ela trata de executar um método chamado Run(), que é o que tipicamente chamamos de "game loop". Este método contém uma instrução "while" que se repete incessantemente enquanto o jogo está rodando, chamando diversas outras funções.

O game loop basicamente trata de executar as seguintes rotinas: mudar para o próximo frame das animações, desenhar o mapa atual, desenhar o jogador, desenhar os inimigos, desenhar as informações atuais, atualizar a tela, verificar se condições como a de "game over" são satisfeitas, detectar colisões, executar o turno dos objetos e inimigos, e aguardar o jogador pressionar uma tecla para fazer alguma coisa, e repetir tudo isso desde o começo.


9. Movimentação e detecção de colisões


Como citado anteriormente, a movimentação de objetos no mapa se dá por substituição dos elementos nas matrizes de objetos.

No entanto, a engine realiza a movimentação de objetos de duas maneiras. A forma citada é como a engine move os objetos comuns, isto é, as instâncias de Object. A outra forma é como ela move os objetos que herdam de Fighter, como Player e Enemy. A instância de Player é um singleton, ou seja, só existe 1 único objeto do tipo Player no sistema, que representa o único jogador. Ao mover este objeto no jogo, a engine manipula as coordenadas X e Y de acordo com a direção, se o movimento for permitido.

Como a engine sabe se o movimento é permitido e quais são os critérios? Antes de efetivamente mover o jogador, a engine primeiro analisa se a direção para a qual o jogador deseja se mover está livre ou se é possível naquele momento ir naquela direção. Para isto, a classe Player é dotada de métodos diversos utilizados para "observar" o mapa atual e descobrir que objetos estão ao redor do jogador. Por exemplo, se o objeto que estiver na direção desejada for sólido, o movimento deve ser proibido, portanto a engine não move o jogador. Os métodos implementados na classe Player são capazes de determinar o tipo do objeto que se encontra em qualquer direção e a qualquer distância do jogador, de forma que antes de mover o jogador é possível "olhar adiante" para saber o que tem na frente dele.

É interessante discutir a implementação do comportamento de alguns tipos de objeto (instâncias de Object). A classe Object implementa um método diferente para cada tipo existente. Por exemplo, o tipo PUSHABLE é implementado pelo método DoPushable(), o tipo QUICKSAND é implementado pelo método DoQuicksand() e assim por diante. Sempre que o jogador efetivamente se move para outra posição, a engine trata de verificar qual é o tipo do objeto que se encontra atualmente colidindo com o jogador, isto é, qual objeto está na mesma linha e coluna da matriz do mapa que as coordenadas Y e X do jogador, respectivamente. Então dependendo do tipo, a engine invoca o método correspondente. Por exemplo, se for do tipo PUSHABLE, o método DoPushable() é invocado. Desta forma, o jogo "reage" de diferentes formas a cada passo do jogador, a depender do objeto sobre o qual este se encontra. Alguns dos métodos que implementam o comportamento dos diversos tipos de objetos recebem parâmetros, sendo que o mais importante é geralmente o parâmetro do próprio objeto.

Ao manipular determinados tipos de objetos comuns (instâncias de Object) também pode ser necessário saber os tipos de objetos ao redor. Um exemplo típico é o tipo PUSHABLE. O tipo PUSHABLE como citado anteriormente implementa pedras ou objetos que o jogador pode empurrar. Quando a classe Player detecta a colisão com um objeto deste tipo, é necessário verificar o que há do outro lado deste objeto, pois um objeto PUSHABLE não pode nem deve ser empurrado por cima de qualquer outro tipo de objeto que seja sólido. Quando há algo sólido do outro lado, a ação de empurrar o objeto é simplesmente ignorada pela engine devido à restrição imposta.

Confira alguns outros objetos de comportamento interessante:

O tipo ONE_WAY implementa um terreno por onde o jogador só pode atravessar em uma determinada direção ou sentido, indicado pelo parâmetro do objeto. Se por exemplo o parâmetro for NORTH, significa que o jogador só pode atravessar este terreno caso esteja indo na direção norte, sendo bloqueada a passagem em qualquer outra direção. Estas restrições são impostas dentro do método DoOneWay() chamado através da engine ao colidir com um objeto do tipo ONE_WAY, que recebe o parâmetro do objeto indicando o sentido permitido.

O tipo WATER_D implementa um terreno aquático usado para criar lagos, rios sem correnteza e outras áreas contendo águas profundas. Ao detectar colisão com este tipo, o método DoWaterDeep() é chamado através da engine para alterar o gráfico do jogador para que pareça estar dentro da água. Outro detalhe é que ao pressionar a tecla para mergulhar, a engine verifica no parâmetro do objeto qual é o ID do mapa subaquático para onde deve transportar o jogador, uma vez que ao mergulhar o jogador é levado para um mapa diferente "debaixo d'água".

O tipo QUICKSAND implementa um terreno de areia movediça. Ao colidir com este terreno, o gráfico do jogador é alterado (ficando igual ao entrar na água), e a cada passo dado neste terreno um contador interno vai decrementando de 1 em 1. Este contador chama-se "sink" e indica que o jogador está "afundando na areia movediça". Ao chegar a 0 o jogador morre. Quando o jogador pisa em outro tipo de terreno, esse contador retorna automaticamente ao nível máximo. O parâmetro deste tipo de objeto no momento é ignorado pela engine.

Até o momento existem quase 40 tipos de objeto definidos, porém nem metade deles foi implementado ainda. E vale lembrar que a implementação dos métodos de comportamento dos tipos de objeto está em Object, não na engine. A engine invoca estes métodos indiretamente através de cada um dos objetos no mapa.


10. Inimigos


Os inimigos são colocados no mapa da mesma forma como os objetos comuns, porém a engine os trata de forma muito diferente. O tipo de objeto que inicialmente representa um inimigo no mapa é Object com o "tipo" definido como ENEMY e o parâmetro indicando o tipo de inimigo. Quando o jogador entra em um mapa, a engine trata de preencher um vetor de inimigos (instâncias da classe Enemy) e são estes objetos que passam a representar os inimigos. Para fazer isso, a engine percorre o mapa inteiro obtendo os Object cujo tipo é ENEMY, então dependendo do parâmetro, cria um objeto Enemy correspondente no vetor de inimigos com o X e Y iguais à coluna e linha da matriz onde ele se encontra, e em seguida remove aquele Object do mapa, uma vez que ele não é mais necessário. Em outras palavras, o Object de tipo ENEMY é apenas um marcador de lugar ao editar os mapas para indicar à engine as posições iniciais dos inimigos e seus tipos.


11. Sistema de batalha


A implementação do sistema de batalha no momento é muito rudimentar, uma vez que apenas um tipo de inimigo foi implementado até agora e só existe o ataque em curta distância sem equipamento. Portanto a discussão a seguir será breve e sem muitos detalhes.

Rhye's Quest é um RPG no estilo "roguelike", e isso significa que para o jogador atacar o inimigo, uma das formas é encostando no inimigo, ou melhor dizendo, colidindo com ele. Quem detecta a colisão entre jogador e inimigo é a classe Player. Ao mover o jogador, a classe verifica se as novas coordenadas coincidem com as coordenadas de algum dos inimigos no vetor de inimigos. Em caso positivo, é realizado um ataque de curta distância. O ataque em longa distância não será discutido pois no momento ainda não existe implementação desta funcionalidade.


Conclusão


Como o desenvolvimento de Rhye's Quest é um trabalho em andamento, e muito longe da conclusão, não há neste momento muitos detalhes para discutir. O que foi exposto neste artigo é apenas uma visão teórica, superficial e rápida sobre o sistema e sobre como as coisas funcionam internamente.

Quaisquer dúvidas, sugestões ou comentários basta deixar sua mensagem e responderei assim que possível.