Tutorial criado por gustavobarbieri
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.
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.
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.
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()
).
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.
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.
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.
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:
- 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).
- Mude o diretório de onde as imagens são lidas para que as novas imagens sejam usadas.
- Desabilite o canal alpha de todas as imagens com
set_alpha()
. - 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 doupdate( 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.