Programação de Jogos Gustavo Sverzut Barbieri

Introdução

Este curso apresentará a implementação passo a passo de um jogo de nave espacial no estilo "side scrolling", porém os conceitos se aplicam para todos os jogos 2-D e até mesmo os 3-D (porém a técnica de mostrar as imagens é diferente).

A implementação usará a linguagem de programação Python e a biblioteca PyGame devido a facilidade de implementação e ao caráter multi-plataforma de ambos. Apesar da implementação ser em uma linguagem orientada a objetos, será indicado como proceder em uma linguagem procedural, portanto um programador de C ou Pascal conseguirá compreender os conceitos.

Para uma abordagem mais completa sobre PyGame e a parte técnica da programação de jogos, vide http://palestras.gustavobarbieri.com.br/pygame/, documento no qual eu descrevo as APIs.

O código fonte do jogo se encontra em http://www.gustavobarbieri.com.br/jogos/jogo.tar.gz. Você deverá acompanhar o curso olhando no conteúdo deste arquivo!

Recursos Utilizados

Neste documento utilizarei das seguintes notações de cores:

Classe
representa uma classe.
função
representa uma função ou método.
variável
representa uma variável ou atributo.

Este texto utiliza-se de recursos DHTML, os título de seção e imagens são clicáveis e se expandem/contraem, facilitando assim a leitura do texto. É recomendado que após as imagens sejam vistas estas sejam contraídas para não atrapalharem.

a01: O Básico de Qualquer Jogo

Qualquer jogo funciona em torno de um laço principal e é por este laço que começamos. Código Fonte.

O jogo será totalmente contido em uma classe básica chamada Game. Esta classe não precisaria existir, poderia ser código direto em uma função main(), porém eu gosto de deixar as coisas bem organizadas e então uso esta classe, a qual vai conter como atributos as variáveis comuns a várias chamadas, tais como o nosso laço principal, tratador de eventos e outras.

No diagrama de classes da versão a01 podemos ver que temos duas classes: Game e Background. A primeira conterá o laço principal loop() e terá uma instância de Background, que representará o fundo do nosso jogo.

a01: classes existentes

Diagrama de Classes da versão a01.

A execução desta versão apresentará apenas uma tela preta, que ficará aberta até que o botão de fechar janela ou a tecla ESC sejam pressionados. Aparentemente isto é inútil porém isso tem tudo que um jogo precisa! Veja o laço principal loop(), ele chama sequencialmente:

handle_events()
Trata os eventos, tomando as ações necessárias.
actors_update( dt )
Atualiza cada ator do jogo, seja ele o fundo, a nave, os inimigos, os tiros, o placar, ou seja, tudo que é dinâmico. O parâmetro passado é utilizado como o tempo, pois se algum ator muda algum parâmetro baseado nisso, ele deve utilizar este valor. Atualizar o ator não é desenhá-lo na tela e sim atualizar o seu estado interno. Em geral cada ator provê um método update() o qual é invocado para fazer tal atualização.
actors_draw()
Nesta fase os atores serão desenhados na tela. A posição de desenho, bem como o conteúdo a ser desenhado, serão determinados a partir do estado interno do jogador. Em geral cada ator provê um método draw( screen ), o qual recebe a tela como parâmetro e faz ele mesmo os desenhos.

Observe que apesar desta estrutura estar modelada em classes, utilizando programação orientada a objetos, ela poderia muito bem ser feita em qualquer linguagem. Por exemplo, em C poderíamos ter estruturas representando cada ator, cada ator conter uma chamada de retorno ("callback") para fazer sua atualização e desenho na tela.

A partir deste modelo básico nós podemos evoluir nosso jogo e é o que faremos a partir de agora. Apenas note que uma boa estrutura para seu projeto pode tornar as coisas muito mais fáceis e é isso que buscamos com este curso.

a02: Movimentação do Plano de Fundo

Nesta versão adicionaremos funcionalidades ao fundo para que assim este pareça estar em movimento. Código Fonte.

Veja o diagrama de classes desta versão, note que adicionamos dois atributos isize e pos, além disso modificamos o construtor da classe para que este receba uma imagem ou nome de imagem como parâmetro e alteramos a função update() para que esta faça alguma coisa e a função draw( screen ) para que desenhe o novo fundo na tela.

As mudanças requeridas foram:

__init__( image )
Agora recebe um parâmetro que pode ser o nome da imagem ou uma imagem do pygame. Caso o parâmetro for um nome, a imagem será lida. Após isso o fundo será construído em uma imagem a qual será usada para a animação.
update( dt )
Passa a atualizar a posição baseado no tempo.
draw( screen )
Passa a desenhar o fundo na tela baseado na posição.
isize
Contém o tamanho da imagem básica.
pos
Contém a posição atual do fundo.
a02: classes existentes

Diagrama de Classes da versão a02.

Técnicas de Construção de Fundos

Para a construção de fundos existem várias técnicas, desde lermos uma imagem imensa que representa todo o fundo do jogo até a leitura de pequenos pedaços que podem ser encaixados lado a lado, chamados de "tile", passando por outras como a montagem a partir de um mapa e peças básicas.

Neste jogo utilizaremos, para manter o código o mais enxuto possível e portanto facilitar a explicação, o sistema de tiles. Foi gerado uma imagem que o lado esquerdo se encaixa ao lado direito e o topo se encaixa no fundo. Estas imagens serão repetidas até formarem uma imagem que seja maior que a tela em 1 tile. Na implementação deste jogo fizemos a imagem ser maior tanto na horizontal quanto na vertical para que possamos experimentar, porém como o movimento é vertical, poderíamos deixar o comprimento horizontal igual e aumentar apenas o vertical. Com este excesso podemos descer a tela até que cheguemos ao fim e então subiremos a tela em 1 tile e começamos a descida novamente, isso dará a impressão que o fundo sempre desce.

Na maioria dos jogos utiliza-se outro sistema de fundos, o baseado em mapas e peças básicas. Neste sistema existem várias peças básicas que se encaixam, cada uma representando um estilo de terreno, depois existe um mapa o qual indica o posicionamento destes terrenos. A montagem do terreno é dinâmica, sendo feita em update( dt ), a qual montará a imagem a ser impressa por draw( screen ).

Veja que devido a boa estrutura do código pudemos alterar o fundo sem ter que tocar no código do jogo. O resultado pode ser visto abaixo.

a02: tela do jogo

tela do jogo a02.

a03: Adicionando Mais Atores

Nesta versão adicionaremos mais atores, começando com os inimigos, os quais são definidos na classe Enemy. Código Fonte.

Antes de codificar temos que pensar: O componente que adicionarei pode ser reutilizado no futuro? Vamos pensar, o inimigo será uma nave, tão como o nosso jogador, portanto ambos devem descender de algo em comum, no nosso caso a classe Ship. E a nave, vai ter algo em comum com mais alguma coisa? Sim! Os tiros serão objetos que se movem, logo eles deverão descender de algo em comum, no caso a classe GameObject. Pensar antes de codificar pode economizar muito esforço mais tarde. Vale notar que se o programa fosse procedural ao invés de orientado a objetos, as classes acima mencionadas seriam estruturas, a herança seria uma outra estrutura com o primeiro componente sendo a classe mãe e a codificação se tornaria bem parecida!

Começaremos a entender a classe GameObject. Como mencionado acima, esta classe representará os objetos móveis. As necessidades de um objeto móvel seriam: a imagem a ser mostrada (image), a velocidade com que o objeto se move (speed), a posição (rect), uma função de atualização (update( dt )), função para desenhá-lo (draw( screen )) e as funções auxiliares para manipular os parâmetros.

A classe Ship deve conter um contador de vidas, pois as naves poderão ter mais de uma vida se desejável.

A classe Enemy é apenas uma nave, sem nada de especial por enquanto, apenas a imagem padrão que vai ser diferente. É interessante ter esta classe em separado pois assim poderemos melhorar sua implementação se for desejado.

Nesta versão faremos uma pequena alteração à classe Game para que esta mantenha uma lista de atores (list) e também uma função que gerencie o jogo, dando ação a ele (manage()).

a03: classes existentes

Diagrama de Classes da versão a03.

a03: tela do jogo

tela do jogo a03.

a04: Adicionando o Personagem Principal

Com esta versão teremos quase um jogo completo, nela adicionaremos o personagem principal, que será representado pela classe Player, o qual poderá se movimentar. Código Fonte.

A classe Player é descendente da classe Ship, porém implementa mais funcionalidades, como o acumulo de experiência (XP), um novo sistema de posicionamento (sobrescreve get_pos()) e uma função de movimentação personalizada (sobrescreve update( dt )). O construtor também foi modificado para utilizar outra imagem e também para iniciar a velocidade em 0, pois não queremos que nossa nave inicie em movimento.

As funções de movimentação foram adicionadas à classe mãe Ship, mesmo não sendo utilizadas por Enemy, é possível que para adicionar mais inteligência aos inimigos tais funções sejam necessárias.

Com a introdução do jogador é possível que este colida com os inimigos, portanto adicionamos as funções do_collision() e is_dead(). A primeira é utilizada pelo jogo para indicar que esta nave colidiu em algo, a segunda é utilizada para saber se a nave ainda sobrevive. Estas funções podem ser sobrescritas posteriormente para que após a colisão ou morte sons e efeitos especiais sejam usados. Como ambos jogador e inimigos podem colidir e morrer, as funções ficam na classe mãe. Game sofreu alterações para que tais métodos fossem utilizados (actors_act()).

Para que o jogador seja utilizado, adicionamos uma instância deste em Game, bem como código ao tratador de eventos handle_events() para que este chame as funções necessárias do jogador. Também acrescentamos o jogador à lista de atores.

a04: classes existentes

Diagrama de Classes da versão a04.

a04: tela do jogo

tela do jogo a04.

a05: Jogo Completo!

Nesta versão completamos o funcionamento do jogo, adicionando os tiros, representados pela classe Fire e também adicionando a passagem de fases. Código Fonte.

A classe Fire é descendente de GameObject e a extende recebendo uma lista a qual ela se adiciona. Isto é necessário para que os tiros entrem para a lista de atores e participem das etapas de atualização, colisão e desenho na tela.

Para que os tiros sejam usados, precisamos alterar a classe Ship (pois todas as naves podem atirar) e implementamos a função fire(). Note que tal função será sobrescrita por Player pois queremos que ao passar de fase o jogador aumente o poder de ataque, para isso adicionamos get_fire_speed(), a qual vai dizer a velocidade e número dos tiros, ao jogador.

Como queremos que nosso jogo possa ter mais efeitos especiais no futuro, adicionamos a função do_hit() à Ship, que por agora vai ter o mesmo comportamento de do_collision(). É interessante tê-las separada pois sons e efeitos diferentes podem ser aplicados a cada caso.

Os tiros podem ser instanciados em dois casos: se o jogador pressionar a tecla ou pelos inimigos, de forma aleatória. O primeiro caso é implementado adicionando código à handle_events(), o segundo com código em manage().

Com os tiros temos outro tipo de colisão a conferir: a dos tiros com as naves. Isto é feito em actors_act(), lembrando de aumentar a experiência do usuário caso ele tenha atingido algum inimigo.

Para implementar a passagem de fase, adicionamos change_level() ao jogo. Esta vai conferir se a experiência é suficiente e então aumentamos o número de vidas do jogador e fazemos a passagem de fase, que no nosso caso é apenas mudar o fundo.

a05: classes existentes

Diagrama de Classes da versão a05.

a05: tela do jogo

tela do jogo a05.

a06: Adicionando Novos Recursos

Nesta versão pegamos o jogo pronto, versão anterior, e modificamos-o para adicionar novos recursos, dentre eles um placares de vida e de experiência e inimigos mais "inteligentes". Algumas optimizações também serão aplicadas. Código Fonte.

Para adicionar o placares de vida e experiência precisamos implementar as classes PlayerLifeStatus e PlayerXPStatus, respectivamente. Depois adicionamos instancias destas ao jogo e modificamos as funções actors_update( dt ) e actors_draw() para que assim os placares sejam atualizados e desenhados na tela. Note que estas classes também poderiam ser descendentes de GameObject, porém como pouquíssimos recursos desta seriam utilizados, resolvemos implementá-las totalmente.

A melhoria em Enemy advém do uso do parâmetro behaviour, o qual será utilizado para dar uma velocidade inicial ao inimigo. Diferentes valores resultarão em diferentes comportamentos (velocidades iniciais). Os inimigos também passarão a ter mais vidas dependendo do nível que o jogo se encontra.

As otimizações são bem simples: leremos cada imagem somente uma vez. Nas versões anteriores a cada nova instância de um objeto a imagem era lida do disco e montada em memória, isto acarretava em desperdício de processamento e memória. Nesta versão utilizamos load_images() para ler as imagens para a memória no início e então passamos a referência desta imagem ao instanciar as classes de objetos.

a06: classes existentes

Diagrama de Classes da versão a06.

a06: tela do jogo

tela do jogo a06.

a07: Otimizando com "Color Key"

Nesta versão fazemos nossa primeira otimização utilizando a técnica de transparência com Color Key. Código Fonte.

Você deve ter percebido que os exemplos até agora consomem muita CPU. A razão disso é que nós atualizamos a tela inteira todas as vezes e ainda todas as nossas imagens são 32bits (RGBA, transparência por pixel). Então, a cada quadro, nós fazemos 800 * 600 * 4 = 1.920.000 operações de mistura de cor para o fundo, só que o fundo nunca é transparente! Além disso, para cada objeto, como a nave, inimigos, tiros e placar, nós também fazemos esta opração de mistura de cor para simular a transparência.

Em geral, nos jogos, pouquíssimas imagens usam transparência por pixel, somente imagens que são "translúcidas". Nas demais imagens utiliza-se a técnica de Color Key na qual uma cor é designada para representar a transparência. Esta técnica também é utilizada no formato de arquivo "GIF".

A adaptação do jogo para utilizar Color Key é extremamente fácil:

  1. Converta suas imagens, retirando a transparência por pixel e usando uma cor para representar os pontos transparentes. As imagens se encontram no diretório imagens-noalpha. A cor escolhida foi o magenta (100% vermelho, 0% verde e 100% azul).
  2. Mude o diretório de onde as imagens são lidas para que as novas imagens sejam usadas.
  3. Desabilite o canal alpha de todas as imagens com set_alpha().
  4. Defina qual a Color Key escolhida com set_color_key().

Agora o jogo já deve consumir um pouco menos de CPU.

a08: Otimizando com "Dirty Rectangles"

Nesta versão fazemos nossa segunda e mais significante otimização utilizando a técnica de Dirty Rectangles. Esta técnica consiste em não pintar a tela inteira toda vez, apenas limpar o que foi sujo no quadro anterior e pintar o que é novo neste quadro. Código Fonte.

Apesar da otimização Color Key ajudar um pouco, o jogo ainda consome muito CPU, pois atualizamos a tela toda, utilizando mais de 800 * 600 * 3 = 1.440.000 operações a cada 1/60 segundos. Ainda é muita coisa!

O problema é que o fundo se movimenta a cada quadro. Se, ao invés de movimentar o fundo toda vez, deixássemos ele parado por alguns quadros (update_threshold), poderíamos então utilizar a técnica de limpar o que mudou e pintar os novos objetos.

O PyGame provê a classe RenderUpdates que implementa a técnica de Dirty Rectangles para nós. Apenas temos que chamar clear() nas instâncias desta classe.

Para nossos objetos PlayerXPStatus e PlayerLifeStatus, que não são do tipo Sprite e também não estão em um RenderUpdates, temos que implementar a técnica manualmente, para isso criamos o método clear() que dado o fundo e a tela, pinta nesta o pedaço do fundo que foi anteriormente "sujo". Também temos que alterar o método draw() para que lembre onde foi sujo.

A classe Background foi alterada de forma a atualizar apenas após um número de quadros update_threshold.

Evoluindo o Projeto

Baseado nas mudanças feitas em a06 podemos evoluir o jogo para algo mais satisfatório. Muitas mudanças precisariam de pouco código e apresentariam muito efeito, dentre elas:

Leitor de Mapas
Um leitor de mapas que montasse todo o terreno do jogo. Isto daria a impressão que o jogo evolui e diminuiria a monotonia. Um leitor de mapas pode ser implementado usando um arquivo de texto no qual cada letra especifica um terreno. O fundo seria montado colocando-se os terrenos lado a lado conforme fosse necessário.
Outros Inimigos
Outros inimigos poderiam ser implementados facilmente, para isso seria necessário modificar a imagem, a experiência XP deste e um pouco mais de inteligência dentro do update( dt ). Inimigos de tamanhos diferentes e ações diferentes fariam com que o jogo parecesse algo muito mais elaborado, porém a implementação é trivial.
Leitor de Fases
Ao invés de gerar inimigos aleatóriamente o jogo poderia ler a posição dos inimigos e instanciá-los quando tal posição entrasse na tela. Isto pode ser implementado similarmente ao leitor de mapas utilizando um arquivo de texto no qual cada letra especifica um tipo de inimigo e sua posição. Isso faria parecer que cada pedaço da fase tem uma determinada dificuldade, relacionando-se com o terreno. Opcionalmente poderia ser implementado um sistema de regras para geração de inimigos baseado no tempo ou na quantidade de inimigos que está na tela.
Sons e Efeitos Especiais
Todo jogo se beneficia de efeitos sonoros, este não seria diferente. Para utilizar sons os objetos deverão ler o arquivo no início (vide as imagens) e quando necessário estes sons deverão ser tocados. Já os efeitos especiais devem ser codificados como descendentes de GameObject, devem ter várias imagens que ao serem passadas em sequência fazem parecer que é uma animação e também podem ter sons. Os efeitos devem ser adicionados a uma lista de efeitos e eliminados (kill()) ao fim da animação. Ao menos que se deseje o objeto de efeito não deve ser conferido por colisão (um outro caso seria os efeitos serem destroços os quais poderiam derrubar os personagens).
Apresentações e Telas de Passagem de Fase
Todo jogo tem telas de apresentação, telas com a história e telas de passagem de fase (possivelmente com as histórias). Desenvolver tais telas é muito fácil: faça um laço em separado que apresente as animações!
Telas de Opções
Um jogo costuma ter telas de opções para que o usuário possa entrar com seu apelido, dificuldade e outros dados que achar interessantes. Uma tela de opções pode ser implementada em um laço separado o qual tratará os eventos, mostrará os resultados e etc. como um outro jogo.

Conclusão

Este curso de introdução à programação de jogos mostrou que é possível escrever um jogo utilizando técnicas bem simples. Tomando cuidado com a estrutura o projeto conseguimos extendê-lo sem ter que alterá-lo drasticamente.

Gustavo Sverzut Barbieri barbierigmail.com.