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:
Classe
função
variável
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.
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()
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 )
isize
pos
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.
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()
).
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.
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.
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.
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.