Você não precisa ver toda a escada, simplesmente dê o primeiro passo! |
O jogo é propositalmente simples para que o leitor com pouca experiência na linguagem Pascal e no desenvolvimento de games possa acompanhar o texto e o código o início ao fim. Iremos utilizar uma abordagem mista de programação estruturada e orientada a objetos já que precisaremos interfacear com algumas bibliotecas de baixo nível, mas sempre buscaremos manter o código simples, comentando e explicando cada bloco que surgir.
O curso está divido em nove partes, com cada uma delas sendo publicada semanalmente aqui no blog. Dúvidas, críticas e sugestões poderão ser postadas livremente nos comentários.
Espero que apreciem.
Iniciando o SDL2
No artigo anterior, criamos a estrutura básica para começar a escrever nosso game e vamos utilizá-la agora. Caso você ainda não tenha configurado seu ambiente, siga as instruções citadas na Parte 0 e, antes de prosseguir, baixe o projeto, descompacte, e abra abara-o no Lázarus.A inicialização do SDL pode falhar por diversos motivos. Pode não haver memória suficiente para que a biblioteca seja carregada, a dll pode não ser encontrada ou estar em um formato inválido para o programa (um programa compilado em 32bits tentando carregar uma biblioteca de 64bits, por exemplo).
O tratamento de erro no SDL é realizado através de códigos de retorno. Cada função, pode retornar um código de erro específico ou 0 se não houve erro algum durante a sua execução. Isto pode parecer um tanto exótico para quem está acostumado ao modo como o código em pascal geralmente é escrito (herança dos tempos da Borland/Inprise), então vamos mapear estes códigos de erro para exceções customizadas e vamos encapsular todas as chamadas à biblioteca em uma classe chamada
Game
.Adicione uma nova unit ao projeto e chame-a de
sdlGame.pas
. Nela, vamos implementar a classe TGame
conforme a definição abaixo.unit sdlGame; {$mode objfpc}{$H+} interface uses sysutils, SDL2; type SDLException = class(Exception); TGame = class private fRunning : boolean; fWindow : PSDL_Window; fRenderer : PSDL_Renderer; procedure Quit; procedure HandleEvents; procedure Render; public constructor Create; destructor Destroy; override; procedure Initialize; procedure Run; property Running: boolean read fRunning; end;Note que nos campos privados, temos dois ponteiros bem interessantes, um para a janela do game (
fWindow
) e outro para o renderizador (fRenderer
).O SDL pode trabalhar com várias janelas (em vários monitores, inclusive) mas, para nosso game, teremos somente uma acessível através deste ponteiro. Além da janela, precisamos de um renderizador para poder trabalhar com a biblioteca. O papel do renderizador é pegar os buffers de memória com dados de imagens, camadas, etc. e passá-los para a placa de vídeo, para que esta possa exibir o resultado na tela (em
fWindow
, no nosso caso).Além destes ponteiros, temos um flag (
fRunning
) que utilizaremos para controlar mais tarde para controlar o estado da instância de TGame.Para iniciar os subsistemas que utilizaremos do SDL, vamos implementar o método
Initialize
.procedure TGame.Initialize; begin if ( SDL_Init( SDL_INIT_VIDEO ) <> 0 )then raise SDLException.Create( SDL_GetError ); fWindow := SDL_CreateWindow( PAnsiChar( 'Delphi Games - Space Invaders' ), SDL_WINDOWPOS_UNDEFINED, SDL_WINDOWPOS_UNDEFINED, 800, 600, SDL_WINDOW_SHOWN); if fWindow = nil then raise SDLException.Create( SDL_GetError );
fRenderer := SDL_CreateRenderer(fWindow, -1,
SDL_RENDERER_ACCELERATED);
if fRenderer = nil then raise SDLException.Create(SDL_GetError); end;
Começamos com uma chamada a
SDL_Init
que recebe, como parâmetro, os flags dos subsistemas que queremos inicializar (somente o subsistema de vídeo, por enquanto).Em seguida, criamos nossa janela com uma chamada a
SDL_CreateWindow
, passando o título da janela, a posição (usamos a constante SDL_WINDOWPOS_UNDEFINED
em x e y para que ela seja criada no centro da tela), o tamanho ( 800 x 600
) e, por fim, um flag para que a janela seja exibida logo após sua criação (SDL_WINDOW_SHOWN
).E finalizamos com a criação do nosso renderizador através da função
SDL_CreateRenderer
, onde passamos o ponteiro para a janela em que ele irá atuar, o índice do dispositivo gráfico em que ele vai rodar (-1
indica que queremos o dispositivo padrão do sistema) e o modo de operação (SDL_RENDERER_ACCELERATED
indica que queremos que o renderizador utilize aceleração de hardeware sempre que possível). Caso qualquer uma das funções falhe, levantamos uma exceção do tipo SDLException
, que definimos logo acima da classe.Game Loop
Depois de termos iniciados a janela e o renderizador, precisamos implementar o loop principal (game loop) do jogo através do método Run.procedure TGame.Run; begin fRunning:= true; while fRunning do begin HandleEvents; Render; end; end;
Bem simples não?
Iremos executar todo o jogo dentro deste loop controlado por
fRunning
que atualizaremos em resposta a dois eventos: 1 quando o usuário fechar a janela e 2: quando o usuário pressionar a tecla ESC
. Passemos então ao método HandleEvents
.procedure TGame.HandleEvents; var event : TSDL_Event; begin while SDL_PollEvent( @event ) = 1 do begin case event.type_ of {quit event é gerado em resposta ao fechamento da janela principal do programa } SDL_QUITEV : fRunning := false;< { keydown acontece sempre que uma tecla é pressionada. } SDL_KEYDOWN : case event.key.keysym.sym of { estamos interessados em responder somente a tecla ESC por enquanto } SDLK_ESCAPE: fRunning := false; end; end; end; end
Em resposta aos vários eventos possíveis de acontecer dentro do sistema operacional em que o programa está rodando, o SDL vai preenchendo uma fila de mensagens com informações relevantes em uma área especial da memória.
Se o usuário digitou uma tecla, por exemplo, uma mensagem do tipo
SDL_KEYDOWN
é enfileirada. Se o usuário clicou em qualquer lugar da janela com o botão esquerdo do mouse, uma mensagem do tipo SDL_BUTTON_LEFT
é enfileirada. E assim por diante.O que fazemos para cada chamada de
HandleEvents
é percorrer toda a fila de eventos pendentes, removê-los de lá, um a um, através do método SDL_PollEvent
e tratar as mensagens que nos interessam, atualizando o estado do jogo para mantê-lo em sincronia com as ações do usuário.Por enquanto, só estamos interessados em saber quando devemos sair e fechar o programa portanto, só implementamos os eventos que
fRunning
deve ser atualizado como false.Por fim, precisamos exibir algo na janela. Como ainda não temos nenhuma imagem, vamos simplesmente desenhar um fundo preto.
procedure TGame.Render; begin SDL_SetRenderDrawColor( fRenderer, 0, 0, 0, SDL_ALPHA_OPAQUE ); SDL_RenderClear( fRenderer ); SDL_RenderPresent( fRenderer ); end;
As três funções acima atuam sobre nosso renderizador
fRenderer
para:- informar que iremos trabalhar com um preto sólido (
SDL_SetRenderDrawColor
), - limpar o conteúdo da memória de vídeo e preenchê-la uniformemente com a cor que acabamos de informar (
SDL_RenderClear
) - Exibir o que foi feito nos passos anteriores na janela associada a
fRenderer
(SDL_RenderPresent
)
procedure TGame.Quit; begin SDL_DestroyRenderer(fRenderer); SDL_DestroyWindow(fWindow); SDL_Quit; end; constructor TGame.Create; begin fRunning:= false; end; destructor TGame.Destroy; begin Quit; inherited; end;
Se estiver com dúvidas ou problemas em compilar, veja como ficou o código da unit sdlGame até aqui.
unit sdlGame; {$mode objfpc}{$H+} interface uses sysutils, SDL2; type SDLException = class(Exception); TGame = class private fRunning : boolean; fWindow : PSDL_Window; fRenderer : PSDL_Renderer; procedure Quit; procedure HandleEvents; procedure Render; public constructor Create; destructor Destroy; override; procedure Initialize; procedure Run; property Running: boolean read fRunning; end; implementation procedure TGame.Initialize; begin if ( SDL_Init( SDL_INIT_VIDEO ) <> 0 )then raise SDLException.Create(SDL_GetError); fWindow := SDL_CreateWindow( PAnsiChar( 'Delphi Games - Space Invaders' ), SDL_WINDOWPOS_UNDEFINED, SDL_WINDOWPOS_UNDEFINED, 800, 600, SDL_WINDOW_SHOWN); if fWindow = nil then raise SDLException.Create(SDL_GetError); fRenderer := SDL_CreateRenderer(fWindow, -1, SDL_RENDERER_ACCELERATED or SDL_RENDERER_PRESENTVSYNC); if fRenderer = nil then raise SDLException.Create(SDL_GetError); end; procedure TGame.Quit; begin SDL_DestroyRenderer(fRenderer); SDL_DestroyWindow(fWindow); SDL_Quit; end; procedure TGame.HandleEvents; var event : TSDL_Event; begin while SDL_PollEvent( @event ) = 1 do begin case event.type_ of SDL_QUITEV : fRunning := false; SDL_KEYDOWN : case event.key.keysym.sym of SDLK_ESCAPE: fRunning := false; end; end; end; end; procedure TGame.Render; begin SDL_SetRenderDrawColor( fRenderer, 0, 0, 0, SDL_ALPHA_OPAQUE ); SDL_RenderClear( fRenderer ); SDL_RenderPresent( fRenderer ); end; constructor TGame.Create; begin fRunning:= false; end; destructor TGame.Destroy; begin Quit; inherited; end; procedure TGame.Run; begin fRunning:= true; while fRunning do begin HandleEvents; Render; end; end; end.
Com a unit compilando sem problemas, altere o código do arquivo do projeto ( space_invaders.lpr ) para utilizar a nova classe.
program space_invaders; {$mode objfpc}{$H+} uses sdlGame; var Game : TGame; begin Game := TGame.Create; try Game.Initialize; Game.Run; finally Game.Free; end; end.
Execute o projeto e... voilà! Uma fantástica tela surgirá diante de seus olhos!
Bom, talvez não tão fantástica assim, eu admito. Mas podemos melhorar.
Carregando e Exibindo Imagens
A primeira coisa a atentar sobre imagens no SDL, é que, por padrão, a biblioteca só suporta arquivos no o formato BMP. Para carregar e abrir as imagens armazenadas como PNG, devemos utilizar uma das suas várias extensões: o SDL Image 2.0.
Baixe as extensões certas pro seu ambiente ( windows de 32bits, no meu caso ) , descompacte o conteúdo no diretório
bin
do projeto e vamos alterar o código para integrar o SDL Image
ao game.Nos bindings que estamos usando, há uma unit chamada
sdl2_image.pas
. Adicione-a à sessão uses
da unit sdlGame.pas
. Agora vamos alterar os métodos TGame.Inicialize
e o TGame.Quit
conforme a listagem que seque.procedure TGame.Initialize; var flags, result: integer; begin if ( SDL_Init( SDL_INIT_VIDEO ) <> 0 )then raise SDLException.Create(SDL_GetError); fWindow := SDL_CreateWindow( PAnsiChar( 'Delphi Games - Space Invaders' ), SDL_WINDOWPOS_UNDEFINED, SDL_WINDOWPOS_UNDEFINED, 800, 600, SDL_WINDOW_SHOWN); if fWindow = nil then raise SDLException.Create(SDL_GetError); fWindowSurface := SDL_GetWindowSurface( fWindow ); if fWindowSurface = nil then raise SDLException.Create(SDL_GetError); fRenderer := SDL_CreateRenderer(fWindow, -1, SDL_RENDERER_ACCELERATED or SDL_RENDERER_PRESENTVSYNC); if fRenderer = nil then raise SDLException.Create(SDL_GetError); flags := IMG_INIT_PNG; result := IMG_Init( flags ); if ( ( result and flags ) <> flags ) then raise SDLImageException.Create( IMG_GetError ); end; procedure TGame.Quit; begin SDL_DestroyRenderer(fRenderer); SDL_DestroyWindow(fWindow); IMG_Quit; SDL_Quit; end;No TGame.Quit, simplesmente adicionamos uma chamada a IMG_Quit, para liberar os recursos utilizados pelo SLD Image 2.0.
Já o TGame.Initialize requer um pouco mais de nossa atenção, porquê o modo de verificar se o retorno da função IMG_Init é diferente do que fizemos até aqui com os métodos da SDL. Ele recebe um inteiro com as flags dos formatos que desejamos suportar ( IMG_INIT_PNG para habilitar a carga dos aquivos PNG que nos interessam agora ) e devolve, como resultado, um outro inteiro com os flags do que foi habilitado com sucesso. No final, comparamos se o que foi inicializado é exatamente o que pedimos nos valendo de uma operação de bits (
bitwise operation
) simples, levantando uma exceção do tipo SDLImageException
que vamos declarar como descendente de SDLException
:type SDLException = class( Exception ); SDLImageException = class( SDLException );
Ok. Agora podemos carregar nossas imagens para a memória RAM e repassá-las para a memória de vídeo! Mas antes, um pouco de teoria...
O SDL pode armazenar buffers de pixel em diversos formatos tanto em memória RAM quanto na VRAM (a memória da sua placa de vídeo). Cada qual com seu próprio conjunto de vantagens e desvantagens aos quais não vamos nos prender neste momento. O que precisamos saber é que quando você manipula os dados em memória RAM, você está utilizando a CPU e quando os manipula na VRAM, o SDL usa, sempre que possível o GPU, tornando o processo mais rápido.
Embora esta explicação possa ser um pouco simplista, ela é suficiente para o momento. Os dois conceitos são implementados na biblioteca como dois structs ( um struct é um tipo de dados semanticamente equivalente aos records do pascal ):
- SDL_Surface - armazena buffers de cor em memória ram
- SDL_Texture - armazena buffers de cor o mais próximo possível da memória de vídeo
function TGame.LoadPNG( const fileName: string ): PSDL_Texture; const IMAGE_DIR = '.\assets\images\'; var temp : PSDL_Surface; begin result := nil; try temp := IMG_Load( PAnsiChar( IMAGE_DIR + fileName ) ); if ( temp = nil ) then raise SDLException.Create( SDL_GetError ) else begin result := SDL_CreateTextureFromSurface( fRenderer, temp ); if ( result = nil ) then raise SDLImageException.Create( IMG_GetError ); end; finally SDL_FreeSurface( temp ); end; end;
Primeiro carregamos a imagem para a memória e, a partir de lá criamos uma textura otimizada para a placa de vídeo presente no sistema, sempre levantando exceções em caso de falha. As rotimas IMG_Load e SDL_CreateTextureFromSurface realmente simplificam este processo.
Agora vamos carregar as imagens. Caso você ainda não tenha baixados os arquivos para acompanhar este texto, baixe-os agora e descompacte o conteúdo do .zip no diretório bin do projeto
Como indicado pela constante IMAGE_DIR, as imagens devem estar em ,\assests\images. Temos uma imagem para cada tipo de objetos que vamos precisar no game. Para facilitar as coisas, crie um tipo enumerado para cada uma delas, e declare um array de texturas ( como uma variável privada de TGame ) para armazenar nossos ponteiros:
TSpriteKind = ( EnemyA, EnemyB, EnemyC, EnemyD, Player, Bunker, Garbage, Explosion, ShotA ); (...) TGame = class private fRunning : boolean; fWindow : PSDL_Window; fWindowSurface : PSDL_Surface; fRenderer : PSDL_Renderer; fSprites : array of PSDL_Surface; (...) procedure TGame.LoadSprites; begin SetLength(fSprites, Ord( High( TSpriteKind ) ) +1 ); fSprites[ integer(TSpriteKind.EnemyA) ] := LoadPNG( 'enemy_a.png' ); fSprites[ integer(TSpriteKind.EnemyB) ] := LoadPNG( 'enemy_b.png' ); fSprites[ integer(TSpriteKind.EnemyC) ] := LoadPNG( 'enemy_c.png' ); fSprites[ integer(TSpriteKind.EnemyD) ] := LoadPNG( 'enemy_d.png' ); fSprites[ integer(TSpriteKind.Player) ] := LoadPNG( 'player.png' ); fSprites[ integer(TSpriteKind.Bunker) ] := LoadPNG( 'bunker.png' ); fSprites[ integer(TSpriteKind.Garbage)] := LoadPNG( 'garbage.png' ); fSprites[ integer(TSpriteKind.Explosion)] := LoadPNG( 'explosion.png' ); fSprites[ integer(TSpriteKind.ShotA) ] := LoadPNG( 'shot_a.png' ); end; procedure TGame.FreeSprites; var i: integer; begin for i:= 0 to Length( fSprites )-1 do begin SDL_FreeSurface( fSprites[ i ] ); fSprites[ i ] := nil; end; end;
Excelente! Agora temos todas as imagens carregadas em suas respectivas texturas e podemos acessá-las através do TSpriteKind equivalente.
Podemos partir para o desenho dos inimigos, se quisermos ( temos tudo à mão para isso ) mas, se pensarmos um pouco, um inimigo é um candidato perfeito para uma nova classe: cada inimigo tem uma posição independente na tela, uma quantidade de dano que pode sofrer antes de morrer, uma imagem ou um conjunto de imagens que o representa e, para facilitar nossa vida, cada inimigo deve saber desenhar a si mesmo. Crie as classes abaixo para implementarmos estas idéias.
TPoint = record X : integer; Y : integer; end; TGameObject = class protected fTexture : PSDL_Texture; fRenderer : PSDL_Renderer; public Position : TPoint; constructor Create( const aRenderer: PSDL_Renderer ); procedure Draw; virtual; abstract; procedure SetTexture( pTexture: PSDL_Texture ); end; TEnemy = class( TGameObject ) public HP : integer; end; TEnemyA = class( TEnemy ) public procedure Draw; override; end; (...) procedure TEnemyA.Draw; var source, destination : TSDL_Rect; begin if ( HP > 0 ) and ( fTexture <> nil ) then begin source.x := 0; source.y := 0; source.w := 16; source.h := 16; destination.x := self.Position.X; destination.y := self.Position.Y; destination.w := 16; destination.h := 16; SDL_RenderCopy( fRenderer, fTexture, @source, @destination) ; end; end;
Definimos nossa classe base TGameObject com as características que comentamos acima. Note que ela é uma classe abstrata e que seus descendentes deverão implementar o método Draw, além disso, ela recebe um ponteiro para um renderizador (fRenderer) em seu construtor e o método SetTexture permite indicarmos com qual textura nossa instância irá trabalhar.
Seguimos especializando TGameObject em uma classe base para todos os inimigos: TEnemy. Esta classe ainda é muito genérica para saber desenhar todos os tipos de inimigos, então vamos criar mais um nível de especialização e chegar classe real de nosso primeiro inimigo: TEnemyA!
Finalmente, podemos renderizar nosso inimigo!
Observe a listagem do método
TEnemyA.Draw
. Tudo o que ela faz é definir um retângulo do tipo SDL_Rect
de origem ( source
) e um de destino ( destination
) e passá-los para SDL_RenderCopy
que se encarrega de copiar os pixels de fTexture
que estão dentro da área definida pelo retângulo source
para a memória de vídeo na posição e tamanho definidos por destination
. Caso os retângulos possuam tamanhos diferentes, o mapeamento destes pixels ocorrerá de forma automática.Se você executar o projeto agora, ainda temos a mesma tela preta. Isto porquê não instanciamos nenhum inimigo. Vamos alterar, mais uma vez a classe TGame para que ela gerencie a criação, a inicialização e a destruição dos inimigos. Também precisamos mexer um pouco no método
TGame.Render
.TGame = class private fRunning : boolean; fWindow : PSDL_Window; fWindowSurface : PSDL_Surface; fRenderer : PSDL_Renderer; fSprites : array of PSDL_Surface; fEnemies : array [0..19] of TEnemy; (...)
procedure TGame.CreateEnemies; var i : integer; enemy : TEnemy; begin for i:= 0 to High( fEnemies ) do begin enemy := TEnemyA.Create( fRenderer ); enemy.HP:= 1; enemy.Position.X := 32 + (i * 32); enemy.Position.Y := 100; enemy.SetTexture( fSprites[ Ord(TSpriteKind.EnemyA) ] ); fEnemies[ i ] := enemy; end; end; procedure TGame.DrawEnemies; var i: integer; begin for i:= 0 to High( fEnemies ) do fEnemies[ i ].Draw; end; procedure TGame.Render; begin SDL_SetRenderDrawColor( fRenderer, 0, 0, 0, SDL_ALPHA_OPAQUE ); SDL_RenderClear( fRenderer ); DrawEnemies; SDL_RenderPresent( fRenderer ); end;
Começamos criando o array fEnemies para armazenar todos os inimigos. As dimensões do array não importam por quanto, 20 é um número arbitrário somente para termos alguns inimigos para exibir.
Depois, partimos para a inicialização dos inimigos. Como só temos o
TEnemyA
implementado, TGame.CreateEnemies
preenche o fEnemies com 20 instâncias do mesmo, e os organiza em uma linha, separando-os em colunas de 32pixels + 32 pixels de margem ( enemy.Position.X := 32 + (i * 32)
) e informando o endereço de memória onde está a textura a ser usada através de uma chamada a enemy.SetTexture
.Em seguida, TGame.DrawEnemies instrui cada inimigo a desenhar a si mesmo quando é chamado a partir de
TGame.Render
. Simples não? A imagem acima mostra como deve estar a aparência de seus inimigos ao final destas alterações.E com isto concluímos a primeira parte de nossa jornada!
Na próxima semana iremos começar explorar as animações e movimentação dos inimigos e iremos implementar os controles do jogador, via teclado e joystick!
Até lá!
Links
.