Criando um Game Completo - Parte 3

Inimigos, pontuação e colisões!
Bem vindo à terceira parte do nosso mini-curso Criando um Game Completo, onde criamos 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 joystics e uma tabela de scores online!

No último artigo, preparamos as fundações e demos os primeiros passos na construção do game. Agora, com as bases prontas, vamos começar a nos concentrar no conteúdo - imagens, animações e interação entre os objetos do jogo e a interface do usuário.

Este post será maior que os outros dois, construiremos objetos mais complexos e resolveremos alguns problemas mais difíceis portanto, é aconselhável baixar o código-fonte e ir acompanhando as explicações utilizando-o como referência. Caso não queira fazer o download, você também pode ver o código no repositório do GitHub.

Abraços, e mão à obra!

Layout


Grade de layout do game.
O Space Invaders original utilizava uma distribuição visual bem estruturada, em forma de grid. Vamos distribuir nosso espaço de maneira similar, quebrando-o em uma grade com células de 32x32px cada. Como os limites da janela são de 800x600px isto nos dá uma tabela com 25 colunas e 18 linhas, com uma pequena sobra que vamos ignorar.

Observe a imagem ao lado. Ela exibe a grade na qual iremos nos basear para criar o layout do game daqui para frente. É muito importante ter um modelo de referência visual quando estamos programando gráficos porquê eles ajudam a julgar a exatidão do posicionamento das coisas com uma simples olhada na tela ao invés de ficar projetando mentalmente as coordenadas dos objetos e comparar o resultado com o que se vê no  monitor.

Dividimos o espaço da seguinte maneira:
  1. Um grupo de células que chamaremos de seção superior (onde os inimigos estão)
  2. Um grupo de células que chamaremos de seção inferior (onde o jogador está)
  3. Células externas que nos servirão de margem. Nem o jogador, nem os inimigos poderão mover-se para além delas.
De posse destas definições, montamos a tabela de constantes abaixo:

Nome Valor Observação
SCREEN_WIDTH 800 Largura da tela
SCREEN_HEIGHT 600 Altura da tela
SCREEN_HALF_WIDTH SCREEN_WIDTH/2 Centro da tela no eixo X
SCREEN_HALF_HEIGHT SCREEN_HEIGHT/2 Centro da tela no eixo Y
DEBUG_CELL_SIZE 32 Tamanho das células do grid
DEBUG_CELL_COUNT_V SCREEN_WIDTH / DEBUG_CELL_SIZE Número de células em 1 linha
DEBUG_CELL_COUNT_H SCREEN_HEIGHT / DEBUG_CELL_SIZE Número de células em 1 coluna


Algumas dessas constantes  já estão declaradas na unit sdlGame. Crie uma unit nova, chame-a de sdlGameTypes e declare cada linha da tabela abaixo como uma constante pública. Vamos começar a melhorar a estruturação do nosso código para que ele possa crescer de maneira controlada então, depois de criar a nova unit, mova também as exceções customizadas que criamos nos textos anteriores. Veja como ficou sdlGameTypes:

unit sdlGameTypes;

{$mode objfpc}{$H+}

interface

uses
  sysutils;

const
  SCREEN_WIDTH       = 800;
  SCREEN_HEIGHT      = 600;
  SCREEN_HALF_WIDTH  = SCREEN_WIDTH div 2;
  SCREEN_HALF_HEIGHT = SCREEN_HEIGHT div 2;
  DEBUG_CELL_SIZE    = 32;
  DEBUG_CELL_COUNT_V = (SCREEN_WIDTH div DEBUG_CELL_SIZE);
  DEBUG_CELL_COUNT_H = (SCREEN_HEIGHT div DEBUG_CELL_SIZE);

type
  SDLException = class( Exception );
  SDLImageException = class( SDLException );
  SDLTTFException = class( SDLException );
  IndexOutOfBoundsException = class( Exception );

implementation

end.

Não esqueça de adicionar sdlGameTypes na cláusula uses da unit sdlGame, para não quebrar a compilação.

Grid de Debug


Precisamos de um gatilho para desenhar ou ocultar o grid explicado na seção anterior. Para isto, um flag de debug em TGame é suficiente. Assim, dependendo do estado do flag, chamamos ou não as rotinas responsável por seu desenho. Para ativar/desativar o modo debug, vamos utilizar a tecle G, de "Grid", já que a tecla D, mais adequada para "Debug" já é utilizada para controlar o jogador.

Declare fDebugView como campo privado de TGame e vamos alterar TGame.HandleEvents para tratar o pressionamento da tecla G.

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_LEFT, SDLK_A  : fPlayer.Input[Ord(TPlayerInput.Left)] := true;
          SDLK_RIGHT, SDLK_D : fPlayer.Input[Ord(TPlayerInput.Right)]:= true;
          SDLK_SPACE         : fPlayer.Input[Ord(TPlayerInput.Shot)] := true;

          SDLK_p             : ScreenShot;
          SDLK_g             : SetDebugView( not fDebugView );
          SDLK_ESCAPE        : fRunning := false;
        end;

      SDL_KEYUP :
        case event.key.keysym.sym of
          SDLK_LEFT, SDLK_A  : fPlayer.Input[Ord(TPlayerInput.Left)] := false;
          SDLK_RIGHT, SDLK_D : fPlayer.Input[Ord(TPlayerInput.Right)]:= false;
          SDLK_SPACE : fPlayer.Input[Ord(TPlayerInput.Shot)] := false;
        end;

      SDL_JOYAXISMOTION :
          case event.jaxis.axis of
      0 : begin
                  fPlayer.Input[Ord(TPlayerInput.Left)] := false;
                  fPlayer.Input[Ord(TPlayerInput.Right)] := false;
                  if event.jaxis.value > 0 then
                     fPlayer.Input[Ord(TPlayerInput.Right)] := true
                  else
                  if event.jaxis.value < 0 then
                     fPlayer.Input[Ord(TPlayerInput.Left)] := true
                  end;
          end;

      SDL_JOYBUTTONUP :
        case  event.jbutton.button of
          0, 1, 2, 3 : fPlayer.Input[Ord(TPlayerInput.Shot)] := false;
        end;

      SDL_JOYBUTTONDOWN :
        case  event.jbutton.button of
          0, 1, 2, 3 : fPlayer.Input[Ord(TPlayerInput.Shot)] := true;
        end;
    end;
  end;
end;

(...)

procedure TGame.SetDebugView(const aValue: boolean);
begin
  fDebugView := aValue;
  fShots.SetDrawMode(GetDrawMode);
  fEnemies.SetDrawMode(GetDrawMode);
end;

(...)

procedure TGame.ScreenShot;
const
  SCREENSHOT_DIR     = '.\screenshots\';
  SCREENSHOT_PREFIX  = 'shot';

  function GetNewFileName: string;
  var
    i: integer;
  begin
    i:= 0;
    result := Format('%s-%.3d.bmp', [SCREENSHOT_DIR + SCREENSHOT_PREFIX, i]);
    while FileExists(result) do
    begin
      result := Format('%s-%.3d.bmp', [SCREENSHOT_DIR + SCREENSHOT_PREFIX, i]);
      Inc(i);
    end;
  end;

var
  surface : PSDL_Surface;
begin
  surface:= nil;
  try
    surface:= SDL_CreateRGBSurface(0, SCREEN_WIDTH, SCREEN_HEIGHT, 32,
                                      $00FF0000, $0000FF00, $000000FF, $FF000000);
    SDL_RenderReadPixels( fRenderer, nil, SDL_PIXELFORMAT_ARGB8888, surface^.pixels, surface^.pitch );
    ForceDirectories(SCREENSHOT_DIR);
    SDL_SaveBMP(surface, GetNewFileName);
  finally
    SDL_FreeSurface( surface );
  end;
end;

(...)

function TGame.GetDrawMode: TDrawMode;
begin
  if (fDebugView) then
     result := TDrawMode.Debug
  else
     result := TDrawMode.Normal;
end;

Há bastante coisa acontecendo aqui. Vamos explicá-las na ordem em que aparecem no código.

A primeira coisa nova, é a captura da tecla P no SDL_KEYUP para chamar a função ScreenShot. Esta é uma rotina serve para capturar "fotos" da tela e salvá-las em disco. Não é necessário para o game em si, mas é um recurso bacana e pode ajudar a simplificar o debug. O que ela faz é criar um PSDL_Surface temporário, copiar os pixels da tela através de SDL_RenderReadPixels e depois salvá-los em disco no formato bitmap usando SDL_SaveBMP.

Logo após, podemos ver que quando capturamos a tecla G, fazemos uma chamada a SetDebugView que vai inverter o valor do flag fDebugView e configurar os objetos do jogo para o modo de desenho equivalente, o que nos leva à próxima modificação.

A função GetDrawMode devolve, como retorno, uma variável do tipo TDrawMode. De onde veio isto?

Assim como TGame agora "sabe" se está em modo de debug ou não, os nossos objetos também deveriam saber e adaptar seu desenho de acordo. Veja:

  TSpriteAnimationType = (
    NoLoop,
    Circular
  );  

(...)

  { TGameObject }

  PGameObject = ^TGameObject;
  TGameObject = class
  private
    function GetSpriteRect: TSDL_Rect;
  strict protected
    fRenderer   : PSDL_Renderer;
    fDrawMode   : TDrawMode;
  protected
    fSprite     : TSprite;
    procedure InitFields; virtual;
  public
    Position : TPoint;
    constructor Create( const aRenderer: PSDL_Renderer ); virtual;
    destructor Destroy; override;
    procedure Update(const deltaTime : real ); virtual; abstract;
    procedure Draw; virtual; abstract;

    procedure CheckCollisions( Suspects: TGameObjectList);
    property DrawMode: TDrawMode read fDrawMode write fDrawMode;
    property Sprite : TSprite read fSprite;
    property SpriteRect : TSDL_Rect read GetSpriteRect;
  end;

(...)

procedure TGameObject.InitFields;
begin
  Position.X := 0;
  Position.Y := 0;
  fSprite    := TSprite.Create;
  fDrawMode  := TDrawMode.Normal;
end;

Declaramos o modo de desenho como um tipo enumerado, adicionamos uma propriedade equivalente a TGameObject e sempre a inicializamos como TDrawMode.Normal.

Por fim, podemos nos concentrar no desenho do grid:

procedure TGame.DrawDebugGrid;

  procedure HighlightSecions;
  var
    lPlayerBoundary : TSDL_Rect; //seção inferior
    lEnemyBoundary  : TSDL_Rect; //seção superior
  begin
    lPlayerBoundary.x := DEBUG_CELL_SIZE;        
    lPlayerBoundary.y := DEBUG_CELL_SIZE * 17;   
    lPlayerBoundary.w := SCREEN_WIDTH - (2 * DEBUG_CELL_SIZE);
    lPlayerBoundary.h := DEBUG_CELL_SIZE;  //1 célula de altura

    lEnemyBoundary.x := DEBUG_CELL_SIZE;     
    lEnemyBoundary.y := DEBUG_CELL_SIZE * 2;    
    lEnemyBoundary.w := SCREEN_WIDTH - (2 * DEBUG_CELL_SIZE); 
    lEnemyBoundary.h := DEBUG_CELL_SIZE * 14;

    SDL_SetRenderDrawBlendMode(fRenderer, SDL_BLENDMODE_BLEND);
    SDL_SetRenderDrawColor(fRenderer, 255, 0, 0, 50);
    SDL_RenderFillRect( fRenderer, @lPlayerBoundary );
    SDL_RenderFillRect( fRenderer, @lEnemyBoundary );
  end;

var
  i, x, y : integer;
begin
  if fDebugView then
  begin
    HighlightSecions;

    SDL_SetRenderDrawBlendMode(fRenderer, SDL_BLENDMODE_BLEND);
    SDL_SetRenderDrawColor(fRenderer, 255, 0, 0, 130);

    //draw horizontal lines
    for i:=0 to DEBUG_CELL_COUNT_H do
    begin
      y := i*DEBUG_CELL_SIZE;
      SDL_RenderDrawLine(fRenderer, 0, y, SCREEN_WIDTH, y);
    end;

    //draw vertical lines
    for i:=0 to DEBUG_CELL_COUNT_V-1 do
    begin
      x := i* DEBUG_CELL_SIZE;
      SDL_RenderDrawLine(fRenderer, x, 0, x, SCREEN_HEIGHT);
    end;

    //draw center lines in green
    SDL_SetRenderDrawColor(fRenderer, 0, 200, 0, 130);
    SDL_RenderDrawLine( fRenderer,  SCREEN_HALF_WIDTH, 0, SCREEN_HALF_WIDTH, SCREEN_HEIGHT);
    SDL_RenderDrawLine( fRenderer,  0, SCREEN_HALF_HEIGHT, SCREEN_WIDTH, SCREEN_HALF_HEIGHT);
  end;
end;

Começamos iluminando as seções do grid com um vermelho semi-transparente dentro de HighlightSecions e calculamos suas dimensões utilizando os valores da tabela de constantes que criamos previamente. Seguimos com o desenho das linhas horizontais e verticais e finalizamos com duas linhas verdes que passam pelo ponto central da tela, dividindo-a em 4 quadrantes.


Arrays Dinâmicos x Listas Genéricas


A linguagem Pascal oferece o recurso de arrays dinâmicos, que fornecem a possibilidade de aumentar ou diminuir o tamanho de um array em tempo de execução. Um recurso realmente muito útil que vínhamos utilizando até então. Mas há um problema. Esta facilidade de uso nos levou a implementar a lógica de inicialização e liberação dos arrays dentro do código de TGame, misturando conceitos e dificultando a expansão do código.

Veja bem, o que queremos de verdade é um objeto capaz de lidar com listas ou coleções de outros objetos, nos escondendo os detalhes de sua implementação. Se por um lado, é fácil redimensionar um array dinâmico, por outro lado não há como adicionar funcionalidades novas a seu comportamento.

O free pascal suporta o conceito de classes genéricas (também chamados de metaprogramação ou de programação de templates em outras linguagens) e, por consequência, também suporta a criação de listas genéricas (veja a documentação para mais detalhes), o que nos fornece um substituto muito apto para nossos arrays. A idéia é encapsular o comportamento de lista em uma classe sem nos preocupar com os algoritmos necessários para mantê-la.

  TGGameObjectList = specialize TFPGObjectList< TGameobject >;

  { TGameObjectList }

  TGameObjectList = class (TGGameObjectList)
  public
    procedure Update(const deltaTime : real ); virtual;
    procedure Draw; virtual;
    procedure SetDrawMode( aDrawMode : TDrawMode);
  end; 

(...)

procedure TGameObjectList.Update(const deltaTime: real);
var
  i: integer;
begin
  for i:=0 to Pred(Self.Count) do
    Self.Items[i].Update( deltaTime );
end;

procedure TGameObjectList.Draw;
var
  i: integer;
begin
  for i:=0 to Pred(Self.Count) do
    Self.Items[i].Draw;
end;

procedure TGameObjectList.SetDrawMode(aDrawMode: TDrawMode);
var
  i: integer;
begin
  for i:=0 to Pred(Self.Count) do
    Self.Items[i].DrawMode := aDrawMode;
end;
    

Primeiro, declaramos TGGameObjectList como uma espcialização do tipo parametrizado, ou genérico, TFPObjectList. Isto vai gerar uma lista especializada em lidar com instâncias de TGameObject. Em seguida, geramos um descendente desta classe para poder implementar os métodos que queremos, como o SetDrawMode mencionado na seção anterior.

Com isto, chegamos a um contêiner mais capaz para nossos objetos. Se quisermos ser ainda mais específicos, e mais claros, podemos declarar listas específicas para TEnemy e para TShot. Declare as classes abaixo, altere todas as referências de fEnemies e fShots para o novo tipo equivalente e seu código voltará a compilar.

TEnemyList = class(TGameObjectList);
TShotList = class(TGameObjectList);

(...)

procedure TGame.Update(const deltaTime : real ) ;
begin
  fPlayer.Update( deltaTime );
  fEnemies.Update( deltaTime );
  fShots.Update( deltaTime );
end;

procedure TGame.DrawGameObjects;
begin
  fPlayer.Draw;
  fEnemies.Draw;
  fShots.Draw;
end;

procedure TGame.FreeGameObjects;
begin
  fEnemies.Free;
  fShots.Free;
end; 

constructor TGame.Create;
begin
  fRunning      := false;
  fJoystick     := nil;
  fDebugView    := false;
  fEnemies      := TEnemyList.Create;
end; 


procedure TGame.CreateGameObjects;
var
  i : integer;
  enemy  : TEnemy;
begin
  for i:= 0 to 119 do
    fEnemies.Add( enemy );

  fPlayer := TPlayer.Create( fRenderer );
  fPlayer.Sprite.Texture.Assign( fTextures[ integer(TSpriteKind.Player)] );
  fPlayer.Sprite.InitFrames(1,1);

  fPlayer.OnShotTriggered:= @doPlayer_OnShot;

  //centraliza o jogador no eixo X
  fPlayer.Position.X := trunc( SCREEN_HALF_WIDTH - ( fPlayer.Sprite.Texture.W / 2 ));

  //posiciona o jogador na 19º linha do grid
  fPlayer.Position.Y := (DEBUG_CELL_SIZE * 18) - fPlayer.Sprite.CurrentFrame.Rect.h;

  fShots := TShotList.Create(true);
end; 


Distribuição dos inimigos


Distribuição final do inimigos na tela. Note como eles estão perfeitamente alinhados à grade.

Agora que temos um grid para nos guiar, vamos distribuir os inimigos num padrão retangular como o da figura acima: 6 linhas contendo 20 inimigos cada, sendo 2 linhas para o inimigo do tipo A, 2 para o tipo B e 2 para o Tipo C nesta ordem com o inimigo tipo C ocupando as primeiras linhas e o tipo A ocupando as linhas de baixo.

Temos 120 inimigos no total ( 6*20 =120 ). Se você já terminou o curso de álgebra do ensino médio deve ser capaz de deduzir as fórmulas para calcular as coordenadas de cada um dos 120 inimigos, se não, não se preocupe, vamos ver, a seguir, como obtê-las.


c0 cl1 c2 c3 c4 c5 c6 c7 c8 c9 c10 c11 c12 c13 c14 c15 c16 c17 c18 c19
l0 00 01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19
l1 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
l2 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59
legenda: l = linha, c = coluna

Observe a tabela acima, ela representa a distribuição do 60 primeiros inimigos.

Sabendo que o número de colunas, que chamaremos de c, é 20 , podemos obter o número da coluna dividindo o índice do inimigo, que chamaremos de i por 20. Por exemplo, o inimigo armazenado no índice 21 da lista está na coluna i/c => 20/21 = 1. A sobra desta divisão nos dirá a que linha o inimigo pertence, portando:
  linha = i div c
  coluna = i mod c
Com isto, podemos calcular os valores das coordenas X e Y, já que sabemos a altura e a largura de cada célula (o valor da constante DEBUG_CELL_SIZE), que chamaremos de k temos
  x = ( i div c ) * k
  y = ( i mod c ) * k
E, como definimos, em nosso layout,  uma margem esquerda de uma célula e uma margem superior de duas, chegamos em
  x = k + (( i div c ) * k )
  y = 2K + (( i mod c ) * k )

Trazendo isto para o código, vamos alterar a função TGame.CreateGameObjects para criar e distribuir corretamente os inimigos em suas posições iniciais na tela.

procedure TGame.CreateGameObjects;
var
  i : integer;
  enemy  : TEnemy;
begin
  for i:= 0 to 119 do
  begin
    case i of
      00..39 :
        begin
          enemy := TEnemyC.Create( fRenderer );
          enemy.Sprite.Texture.Assign( fTextures[ integer(TSpriteKind.EnemyC) ] );
          enemy.Sprite.InitFrames(1, 2);
        end;

      40..79 :
        begin
          enemy := TEnemyB.Create( fRenderer );
          enemy.Sprite.Texture.Assign( fTextures[ integer(TSpriteKind.EnemyB) ] );
          enemy.Sprite.InitFrames(1, 2);
        end;

      80..119 :
        begin
          enemy := TEnemyA.Create( fRenderer );
          enemy.Sprite.Texture.Assign( fTextures[ integer(TSpriteKind.EnemyA) ] );
          enemy.Sprite.InitFrames(1, 2);
        end;
    end;
    enemy.Position.X := DEBUG_CELL_SIZE + ( i mod 20 ) * DEBUG_CELL_SIZE ;
    enemy.Position.Y := 2* DEBUG_CELL_SIZE + ( i div 20 ) * DEBUG_CELL_SIZE ;
    fEnemies.Add( enemy );
  end;

  fPlayer := TPlayer.Create( fRenderer );
  fPlayer.Sprite.Texture.Assign( fTextures[ integer(TSpriteKind.Player)] );
  fPlayer.Sprite.InitFrames(1,1);
  fPlayer.OnShotTriggered:= @doPlayer_OnShot;

  fPlayer.Position.X := trunc( SCREEN_HALF_WIDTH - ( fPlayer.Sprite.Texture.W / 2 ));
  fPlayer.Position.Y := (DEBUG_CELL_SIZE * 18) - fPlayer.Sprite.CurrentFrame.Rect.h;

  fShots := TShotList.Create(true);
end;

Vamos também diferenciar um inimigo do outro para que eles tenham pontos de vida diferentes.

  { TEnemyA }

  TEnemyA = class( TEnemy )
  protected
    procedure InitFields; override;
  end;

  { TEnemyB }

  TEnemyB = class( TEnemy )
  protected
    procedure InitFields; override;
  end;


  { TEnemyC }

  TEnemyC = class( TEnemy )
  protected
    procedure InitFields; override;
  end;


  { TEnemyD }

  TEnemyD = class( TEnemy )
  protected
    procedure InitFields; override;
  end;

(...)

{ TEnemyC }

procedure TEnemyC.InitFields;
begin
  inherited InitFields;
  fHP:= 3;
end;

{ TEnemyB }

procedure TEnemyB.InitFields;
begin
  inherited InitFields;
  fHP:= 2;
end;

{ TEnemyA }

procedure TEnemyA.InitFields;
begin
  inherited InitFields;
  fHP := 1;
end;

{ TEnemyD }

procedure TEnemyD.InitFields;
begin
  inherited InitFields;
  fHP:= 4;
end;

Restringindo os movimentos


Vamos aproveitar que estamos com essas fórmulas frescas na cabeça e restringir os movimentos do jogador para que ele não saia da seção inferior do layout. Altere TPlayer.Update conforme o código a seguir.

procedure TPlayer.Update(const deltaTime: real);
begin
  if fInput[Ord(TPlayerInput.Left)] then
  begin
     Position.X:= Position.X - ( fSpeed * deltaTime );
     if Position.X < DEBUG_CELL_SIZE then
        Position.X:= DEBUG_CELL_SIZE;
  end;

  if fInput[Ord(TPlayerInput.Right)] then
  begin
     Position.X:= Position.X + ( fSpeed * deltaTime );
     if Position.X > ((DEBUG_CELL_SIZE * 24) - Self.Sprite.CurrentFrame.Rect.w)  then
        Position.X:= ((DEBUG_CELL_SIZE * 24) - Self.Sprite.CurrentFrame.Rect.w);
  end;

  if fCooldownCounter > 0 then
     Dec(fCooldownCounter, trunc(deltaTime * MSecsPerSec));

  if ( (fInput[Ord(TPlayerInput.Shot)]) and ( fCooldownCounter <= 0) ) then
  begin
    if Assigned(fOnShotTriggered) then
       fOnShotTriggered(self);
    fCooldownCounter := fCooldown;
    fInput[Ord(TPlayerInput.Shot)] := false;
  end;

end;

Quanto aos inimigos, vamos deixá-los parados em seus lugares por enquanto, pois isto irá facilitar muito as coisas no decorrer do próximo tópico onde iremos tratar as colisões entre os objetos. Voltaremos à movimentação dos inimigos em outro momento.


Tratamento de Colisões


Inimigos e seus retângulos de colisão
Uma das etapas mais delicadas no loop de um game é a checagem de colisão. Neste passo, devemos iterar por todos os objetos visíveis, checar se eles "tocam" um ao outro e tomar alguma decisão quando isto ocorrer. Se um tiro tocar um inimigo, por exemplo, ele deve sofrer um dano e o tiro será destruído.

Detecção de colisões é um assunto extenso, mas para nosso game só há um tipo de colisão possível, a colisão entre retângulos que, na prática se reduz  a verificar se há alguma intersecção ente dois deles. A SDL, novamente, possui uma função para nos ajudar. Confira a documentação de SDL_HasIntersection e veja como tudo o que ela precisa é de dois retângulos. Exatamente os retângulos que definem os limites de cada frame que nossa classe de sprite possui.

Voltando a nosso código, vemos que TSpriteFrame possui uma propriedade Rect e que este retângulo define as dimensões do sprite, mas não sabe nada sobre sua posição. Vamos adicionar um novo método para resolver isto.

  TSpriteFrame = class
  public
    Rect      : TSDL_Rect;
    TimeSpan  : Cardinal; //em milisegundos
    function GetPositionedRect( position : TPoint ) : TSDL_Rect; inline;
  end;

(...)

// retorna o retângulo do sprite transladado (ou transportado) para o ponto "position"
function TSpriteFrame.GetPositionedRect(position : TPoint): TSDL_Rect;
begin
  result.x := trunc(position.X);
  result.y := trunc(position.Y);
  result.h := self.Rect.h;
  result.w := self.Rect.w;
end;

(...)

function TGameObject.GetSpriteRect: TSDL_Rect;
begin
  result := fSprite.CurrentFrame.GetPositionedRect( self.Position );
end;

Agora podemos percorrer uma lista qualquer de objetos e verificar se eles colidem, levando um evento eqivalente. Veja.

//assinatura do evento que vamos levantar quando uma colisão for detectada
TGameObjectCollisonEvent = procedure(Sender, Suspect: TGameObject; var StopChecking: boolean) of object;

(...)

//classe TGameObject atualizada.
// note a presença o evento OnCollided e do método CheckCollisions
  TGameObject = class
  private
    function GetSpriteRect: TSDL_Rect;
  strict protected
    fRenderer   : PSDL_Renderer;
    fDrawMode   : TDrawMode;
    fOnCollided : TGameObjectCollisonEvent;
  protected
    fSprite     : TSprite;
    procedure InitFields; virtual;
  public
    Position : TPoint;
    constructor Create( const aRenderer: PSDL_Renderer ); virtual;
    destructor Destroy; override;
    procedure Update(const deltaTime : real ); virtual; abstract;
    procedure Draw; virtual; abstract;

    procedure CheckCollisions( Suspects: TGameObjectList);
    property DrawMode: TDrawMode read fDrawMode write fDrawMode;
    property Sprite : TSprite read fSprite;
    property SpriteRect : TSDL_Rect read GetSpriteRect;

    property OnCollided : TGameObjectCollisonEvent read fOnCollided write fOnCollided;
  end;

(...)

//implementação da checagem de colisões
procedure TGameObject.CheckCollisions( Suspects: TGameObjectList );
var
  i              : integer;
  myRect         : TSDL_Rect;
  suspectRect    : TSDL_Rect;
  aStopCheckhing : boolean;
begin
  aStopCheckhing  := false;
  myRect := GetSpriteRect;
  for i:= 0 to Pred( Suspects.Count ) do
  begin
    suspectRect := Suspects[i].SpriteRect;
    if ( SDL_HasIntersection(@myRect, @suspectRect) ) = SDL_TRUE then
       if Assigned( fOnCollided ) then
       begin
         fOnCollided( self, Suspects[i], aStopCheckhing );
         if aStopCheckhing then
            break;
       end;
  end;

end;


CheckCollisions recebe uma lista de objetos "suspeitos" e percorre-os buscando por uma colisão se valendo de HSD_HasIntersection para tal. Se uma colisão for detectada, chamados o evento OnCollided e damos ao callback a chance de parar a com o loop de checagens através da variável aStopCheckhing.

Com a rotina de colisão implementada em TGameObject, vamos varrer todos os tiros disparados pelo jogador e verificar se colidem com algum inimigo. Poderíamos testar todas as instâncias de TShot contra todas as instâncias de TEnemy, mas um controle mais granular é desejável por uma questão de performance e, principalmente,  para mantermos o código limpo e fácil de acompanhar. Para evitar isto, vamos implementar rotinas de filtro em nossas listas.

//retorna uma lista com todos os projéteis que estão seguindo a direção "aDirection"
function TShotList.FilterByDirection(aDireciton: TShotDirection): TShotList;
var
  i: integer;
  shot : TShot;
begin
  result := TShotList.Create( false );
  for i:=0 to Pred(Self.Count) do
    begin
      shot := TShot(Self.Items[i]);
    if (shot.Visible) and (shot.Direction = aDireciton) then
       result.Add( Self.Items[i] );
    end;
end;

(...)

//retorna uma lista com todos inimigos vivos ou todos os mortos
function TEnemyList.FilterByLife(aAlive: boolean): TEnemyList;
var
  i : integer;
  enemy : TEnemy;
begin
  result := TEnemyList.Create( false );
  for i:=0 to Pred( Self.Count ) do
  begin
    enemy := TEnemy(Self.Items[i]);
    if aAlive then
      if enemy.HP > 0 then
         result.Add( enemy )
    else
      if enemy.HP <= 0 then
         result.Add( enemy );
  end;
end;

Finalmente podemos testar, em TGame, as colisões entre os tiros disparados realizados pelo jogador e os inimigos presentes na tela.

(* Busca por colisões entre os tiros do jogador (aqueles que segem para cima)
   e os inimigos ainda vivos *)
procedure TGame.CheckCollision;
var
  i           : integer;
  shotList    : TShotList;
  suspectList : TEnemyList;
begin
  //check all shots going upwards with all alive enemies
  if (fShots.Count > 0) and ( fEnemies.Count > 0 ) then
  try
    shotList    := fShots.FilterByDirection( TShotDirection.Up );
    suspectList := fEnemies.FilterByLife( true );
    for i:=0 to Pred(shotList.Count) do
      TShot(shotList[i]).CheckCollisions( suspectList );
  finally
    shotList.Free;
    suspectList.Free;
  end;
end; 

(...)

(* alteramos também o game loop para incluir as checagens de colisão
   logo após o estado dos objetos ter sido atualizado. *)
procedure TGame.Run;
var
  deltaTime : real;
  thisTime, lastTime : UInt32;
begin
  deltaTime := 0.0;
  thisTime  := 0;
  lastTime  := 0;
  fRunning  := true;
  while fRunning do
  begin
    thisTime  := SDL_GetTicks;
    deltaTime := real((thisTime - lastTime) / MSecsPerSec);
    lastTime  := thisTime;

    CheckDevices;
    HandleEvents;
    Update( deltaTime );
    CheckCollision;
    Render;

    SDL_Delay(1);
  end;
end;

(...)

(*
   Nova implementação de TEnemy.Draw para introduzir o modo de desenho de debug
   que, além do inimigo, exibe na tela seu próprio retângulo de colisão.
   O inimigo também é desenhado com uma cor diferente dependendo da quantidade
   de HP que lhe resta
*)
procedure TEnemy.Draw;
var
  source, destination : TSDL_Rect;
begin
  source      := Sprite.CurrentFrame.Rect;
  destination := Sprite.CurrentFrame.GetPositionedRect(self.Position);


  SDL_SetTextureColorMod( fSprite.Texture.Data, 255, 255, 255);
  if ( HP > 0 ) and ( fSprite.Texture.Data <> nil ) then
  begin
    if self is TEnemyB then
      case HP of
        1 : SDL_SetTextureColorMod( fSprite.Texture.Data, 255, 0, 0);
      end;

    if self is TEnemyC then
    case HP of
      2 : SDL_SetTextureColorMod( fSprite.Texture.Data, 255, 0, 0);
      1 : SDL_SetTextureColorMod( fSprite.Texture.Data, 200, 0, 0);
    end;
    SDL_RenderCopy( fRenderer, fSprite.Texture.Data, @source, @destination) ;

    if fDrawMode = TDrawMode.Debug then
    begin
        SDL_SetRenderDrawColor( fRenderer, 0, 255, 0, 255 );
        SDL_RenderDrawRect( fRenderer, @destination );
    end;
  end;

end;


Por fim, vamos tratar o evento disparado quando uma colisão for encontrada, infringindo o dano no inimigo, destruindo o projétil e adicionando pontos ao score (10 para cada hit e 100 para cada morte).

//amarramos o evento OnCollided de cada tiro disparado no momento de sua criação
procedure TGame.doPlayer_OnShot(Sender: TGameObject);
var
  player : TPlayer;
  shot : TShot;
begin
  if (Sender is TPlayer) then
  begin
    player  := TPlayer(Sender);
    shot := TShot.Create( fRenderer );
    shot.Sprite.Texture.Assign( fTextures[ Ord(TSpriteKind.ShotA) ] );
    shot.Sprite.InitFrames( 1,1 );
    shot.Position := player.ShotSpawnPoint;
    shot.Position.X -= (shot.Sprite.CurrentFrame.Rect.w / 2);
    shot.OnCollided := @doShots_OnCollided;
    shot.DrawMode  := GetDrawMode;
    fShots.Add( shot );
  end;
end;

(...)

//e implementamos o evento em TGame
procedure TGame.doShots_OnCollided(Sender, Suspect: TGameObject; var StopChecking: boolean);
var
  shot  : TShot;
  enemy : TEnemy;
begin
  if ( Sender is TShot )  then
  begin
    if (Suspect is TEnemy) and (TEnemy(Suspect).HP > 0) then
    begin
      shot  := TShot(Sender);
      enemy := TEnemy(Suspect);
      enemy.Hit( 1 );

      if enemy.Alive then
         Inc(fScore, 10)
      else
         Inc(fScore, 100);

      //destruímos o tiro
      fShots.Remove( shot );

      //o tiro foi destruído, não precisamos mais seguir com o loop de checagens
      StopChecking := true; 
    end;
   end;
end;


Fontes TTF


Para exibir os pontos que o jogador acumulou ao acertar os inimigos, precisamos ser capazes de exibir texto na tela. Poderíamos criar nossa própria lógica para exibição de fontes em bitmap no game, mas há uma solução mais prática: podemos utilizar uma das inúmeras fontes True Type disponíveis na internet.

A SDL não suporta fontes de maneira nativa. Precisamos recorrer a outa de suas extensões o SDL TTF (TTF é abreviatura de True Type Font). O processo de integração já é o meso que fizemos com o SDL_Image, baixe as dlls no site do projeto, descompacte-os no diretório.\bind e proto, podemos inicializar a biblioteca chamando a função TTF_Ini.

rocedure TGame.Initialize;
var
  flags, result: integer;
begin
  if ( SDL_Init( SDL_INIT_VIDEO or SDL_INIT_TIMER or SDL_INIT_JOYSTICK  ) <> 0 )then
    raise SDLException.Create(SDL_GetError);

  fWindow := SDL_CreateWindow( PAnsiChar( WINDOW_TITLE ),
                             SDL_WINDOWPOS_UNDEFINED,
                             SDL_WINDOWPOS_UNDEFINED,
                             SCREEN_WIDTH,
                             SCREEN_HEIGHT,
                             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 );
  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 );

  result := TTF_Init;
  if ( result <> 0 ) then
    raise SDLTTFException( TTF_GetError );

  LoadTextures;
  CreateFonts;
  CreateGameObjects;
  fGameText := TGameTextManager.Create( fRenderer );
end;

(...)

procedure TGame.CreateFonts;
const
  FONTS_DIR = '.\assets\fonts\';
begin
  fGameFonts := TGameFonts.Create( fRenderer );
  fGameFonts.LoadFonts( FONTS_DIR );
end;

Note a presença da rotina CreateFonts e do tipo SDLTTFException na nova versão de TGame.Initialize. Também há uma nova classe: TGameFonts que vamos criar para encapsular a lógica de criação das fontes.

Além de uma classe para gerenciar as fontes, vamos precisar de mais uma para encapsular a lógica de desenho dos textos. Busque no código do projeto de exemplo pela unit sdlGameText e você encontrará a implementação de todas estas classes.

Não vamos explicar os detalhes da implementação aqui para não ficar muito massante, já que o código é muito parecido com o que desenvolvemos para carregar as imagens dos sprites. O que você precisa saber é que ele vai buscar os arquivos de fontes no diretório '.\assets\fonts' então, caso não ainda não os tenha, baixe o pacote com os assets atualizados e utilize as fontes ttf que estão disponíveis.


HUD


A interface do usuário é muito simples. Consiste, basicamente de uma barra no canto superior exibindo algumas informações pertinentes como a pontuação do jogo e uma mensagem sobre o status do joystick. Nesta versão há um contador de tiros que iremos substituir mais à frente por um contador de vidas (o jogador não pode morrer nesta versão, então ainda não vamos nos ater a contar suas vidas).

Vamos atualizar TGame.Render para incluir os elementos do HUD.

(* render alterado para desenhar o GUI, ou HUD *)
procedure TGame.Render;
begin
  SDL_SetRenderDrawColor( fRenderer, 0, 0, 0, SDL_ALPHA_OPAQUE );
  SDL_RenderClear( fRenderer );

  DrawGameObjects;
  DrawDebugInfo;
  DrawGUI;

  SDL_RenderPresent( fRenderer );
  fFrameCounter.Increment;
end;

(* rotina atualizada para exibir um texto, além do led, 
   indicando o status do joystick  *)
procedure TGame.DrawDebugInfo;
var
  source, dest : TSDL_Rect;
begin
  DrawDebugGrid;

  //desenha o led do sensor do controle
  dest.x := 10;
  dest.y := DEBUG_CELL_SIZE div 2;
  dest.w := 16;
  dest.h := 16;

  source.y := 0;
  source.w := 16;
  source.h := 16;
  if SDL_NumJoysticks > 0 then
  begin
    source.x := 0;
    //desenha o texto utilizando a fonte "DebugNormal", veja sdlGameText para mais detalhes
    fGameText.Draw('GAME CONTROLLER FOUND', 32, 19, fGameFonts.DebugNormal);
  end
  else
  begin
    source.x := 16;
    //desenha o texto utilizando a fonte "DebugError", veja sdlGameText para mais detalhes
    fGameText.Draw('GAME CONTROLLER NOT FOUND', 32, 19, fGameFonts.DebugError);
  end;
  SDL_RenderCopy( fRenderer, fTextures[Ord(TSpriteKind.Leds)].Data, @source, @dest);
end;

(* Finalmente, desenhamos a linha que limita a nossa barra de informações
   e desenhos o texto com a pontuação do jogador centralizada na tela *)
procedure TGame.DrawGUI;
begin
  SDL_SetRenderDrawColor(fRenderer, 255, 255, 0, 255);
  SDL_RenderDrawLine( fRenderer,  0,
                                  round(DEBUG_CELL_SIZE * 1.5),
                                  SCREEN_WIDTH,
                                  round(DEBUG_CELL_SIZE * 1.5));

  fGameText.Draw( Format('SCORE %.6d', [fScore]),  290, 12, fGameFonts.GUI  );
end;


Bom, com isto finalizamos a terceira parte da série.

Se você chegou até aqui, meus parabéns. Foi um bocado de informação nova, eu sei, mas está valendo a pena. O projeto está começando a parecer um game de verdade e estamos a um passo de dar vida aos inimigos e tocar alguns efeitos sonoros!

Muito obrigado e até a próxima!

Links