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!
Neste documento utilizarei das seguintes notações de cores:
ClassefunçãovariávelEste 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.
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.
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()actors_update( dt )update()
o qual é invocado para fazer tal atualização.
actors_draw()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.
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 )update( dt )draw( screen )isizepos
Diagrama de Classes da versão a02.
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.
tela do jogo a02.
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()).
Diagrama de Classes da versão a03.
tela do jogo a03.
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.
Diagrama de Classes da versão a04.
tela do jogo a04.
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.
Diagrama de Classes da versão a05.
tela do jogo a05.
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.
Diagrama de Classes da versão a06.
tela do jogo a06.
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:
set_alpha().
set_color_key().
Agora o jogo já deve consumir um pouco menos de CPU.
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.
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:
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.
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).
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.