Criando um Game Completo - Parte 1

Você não precisa ver toda a escada,
simplesmente dê o primeiro passo!
Bem vindo à primeira parte do nosso mini-curso Criando um Game Completo, onde iremos criar, do zero, uma versão do clássico Space Invaders compatível com Windows e Linux, utilizando aceleração de hardware para gráficos 2D, suporte a joysticks e uma tabela de scores online!

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:
  1. informar que iremos trabalhar com um preto sólido (SDL_SetRenderDrawColor),
  2. limpar o conteúdo da memória de vídeo e preenchê-la uniformemente com a cor que acabamos de informar (SDL_RenderClear)
  3. Exibir o que foi feito nos passos anteriores na janela associada a fRenderer (SDL_RenderPresent)
Finalize a implementação inicial de TGame com os métodos abaixo para inicializar fRunning no construtor e liberar os objetos da SDL do destrutor da classe.

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

Vamos utilizar para nosso game, uma versão ligeiramente modificada das imagens desenhadas por Joel Shapiro e, gentilmente disponibilizadas na internet.

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 ):

  1. SDL_Surface - armazena buffers de cor em memória ram
  2. SDL_Texture - armazena buffers de cor o mais próximo possível da memória de vídeo
Sabendo disto, veja como ficou a rotina para carregar imagens PNG para uma SDL_Texture:

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_RenderCopyque 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


.