Criando um Game Completo - Parte 2

Modelo do controle usado para os testes
Bem vindo à segunda 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, criamos uma classe para encapsular a lógica do game, carregamos algumas imagens para a memória e exibimos o primeiro inimigo na tela.  Seguiremos, agora, com a animação dos sprites e a movimentação do jogador através do teclado ou do joystick.

Vamos lá!



Sprites e Animação (sprite based)


No código desenvolvido na versão anterior, armazenamos as texturas em um array de PSDL_Texture dentro de TGame e passamos o ponteiro da textura para cada sprite instanciado.

Isto funciona, mas há um problema. O PSDL_Texture é implementado na biblioteca como um ponteiro não tipado ( void * ), logo não há como o sprite saber as dimensões da textura e acabamos enchendo o código de números mágicos quee precisamos eliminar. Começaremos criado uma classe com estas informações para representar uma textura e, em seguida vamos alterar o código de carga das imagens para devolver uma instância dessa nova classe.

TSpriteKind = (
  EnemyA    = 0,
  EnemyB    = 1,
  EnemyC    = 2,
  EnemyD    = 3,
  Player    = 4,
  Bunker    = 5,
  Garbage   = 6,
  Explosion = 7,
  ShotA     = 8,
  Leds      = 9
);

(...)

TTexture = class
  W : integer;
  H : integer;
  Data: PSDL_Texture;
  procedure Assign( pTexure: TTexture );
end;

(...)

procedure TTexture.Assign(pTexure: TTexture);
begin
  self.W    := pTexure.W;
  self.H    := pTexure.H;
  self.Data := pTexure.Data;
end;

(...)

TGame = class
private
  fRunning        : boolean;
  fWindow         : PSDL_Window;
  fWindowSurface  : PSDL_Surface;
  fRenderer       : PSDL_Renderer;
  fTextures       : array of TTexture;

  procedure LoadTextures;
  procedure FreeTextures;

(...)

function TGame.LoadPNGTexture( const fileName: string ): TTexture;
const
  IMAGE_DIR = '.\assets\images\';
var
  temp : PSDL_Surface;
begin
  result := TTexture.Create;
  result.W := 0;
  result.H := 0;
  result.Data := nil;
  try
    temp := IMG_Load( PAnsiChar( IMAGE_DIR + fileName ) );
    if ( temp = nil ) then
       raise SDLException.Create( SDL_GetError )
    else
      begin
        result.W := temp^.w;
        result.H := temp^.h;
        result.Data := SDL_CreateTextureFromSurface( fRenderer, temp );
        if ( result.Data = nil ) then
           raise SDLImageException.Create( IMG_GetError );
      end;
  finally
    SDL_FreeSurface( temp );
  end;
end;

(...)

procedure TGame.LoadTextures;
begin
  SetLength(fTextures, Ord( High( TSpriteKind ) ) +1 );

  fTextures[ ord(TSpriteKind.EnemyA) ]   := LoadPNGTexture( 'enemy_a.png' );
  fTextures[ ord(TSpriteKind.EnemyB) ]   := LoadPNGTexture( 'enemy_b.png' );
  fTextures[ ord(TSpriteKind.EnemyC) ]   := LoadPNGTexture( 'enemy_c.png' );
  fTextures[ ord(TSpriteKind.EnemyD) ]   := LoadPNGTexture( 'enemy_d.png' );
  fTextures[ ord(TSpriteKind.Player) ]   := LoadPNGTexture( 'player.png' );
  fTextures[ ord(TSpriteKind.Bunker) ]   := LoadPNGTexture( 'bunker.png' );
  fTextures[ ord(TSpriteKind.Garbage)]   := LoadPNGTexture( 'garbage.png' );
  fTextures[ ord(TSpriteKind.Explosion)] := LoadPNGTexture( 'explosion.png' );
  fTextures[ ord(TSpriteKind.ShotA) ]    := LoadPNGTexture( 'shot_a.png' );
  fTextures[ ord(TSpriteKind.Leds) ]     := LoadPNGTexture( 'leds.png' );
end;
 
procedure TGame.FreeTextures;
var
  i: integer;
begin
  for i:= low(fTextures) to High(fTextures) do
 SDL_FreeSurface( fTextures[ i ].Data );
  SetLength(fTextures, 0);
end;

Veja que criamos mais uma entrada registro TSpriteKind para armazenar uma imagem a mais - um led que utilizaremos mais à frente. Seguimos com a definição TTexture que, além de um ponteiro para a textura, possui variáveis para armazenar a as dimensões da imagem para qual TTexture.Data aponta. Substituímos campo fSprites por fTextures, e os métodos LoadSprites e FreeSprirtes viraram, respectivamente, LoadTextures e FreeTextures. Verifique seu código e faça as alterações necessárias até conseguir compilar.

Com estas alterações, preparamos o terreno para criar uma classe para nossos sprites.

Vamos definir um sprite como sendo uma imagem que contém todos os frames de uma animação perfeitamente alinhadas em sequência de exibição, formando uma grade regular indexada em 0, exatamente como um array. Observe a imagem abaixo e veja como esperamos que os quadros estejam distribuídos pela imagem.

No exemplo, temos uma imagem de 160px de largura por uma altura H qualquer. Nela existem 6 frames de 32px cada, já que 192/32 = 6, distribuídos em uma única linha. Os números no lado superior esquerdo são os índices dos frames dentro


Exemplo de sprite sheet contendo 6 frames alinhados em uma única linha


Em nossa implementação, a imagem estará armazenada em um TTexture e cada frame será representado por um retângulo armazenado em um  SDL_Rect. Além disto, cada frame deverá saber quanto tempo ficará visível durante a execução da animação a qual pertence.

TSpriteFrame = class
public
  Rect      : TSDL_Rect;
  TimeSpan  : Cardinal; //em milisegundos
end;

TSpriteAnimationType = (
  NoLoop,
  Circular
);

TSprite = class
strict private
const
  DEFAULT_FRAME_DELAY = 500; //em milisegundos
var
  fTexture             : TTexture;
  fFrames              : array of TSpriteFrame;
  fFrameIndex          : integer;
  fAnimationType       : TSpriteAnimationType;
  fTimeSinceIndexChange : real;

  function GetCurrentFrame: TSpriteFrame;
  function GetFrameIndex: integer;
  function GetFrames(index: integer): TSpriteFrame;
  procedure FreeFrames;
  procedure AdvanceFrames(count: integer);
public
  constructor Create;
  destructor Destroy; override;
  procedure InitFrames( const pRows, pColumns : integer );
  procedure Update(const deltaTime : real);

  property Texture: TTexture read fTexture write fTexture;
  property FrameIndex: integer read GetFrameIndex;
  property Frames[index: integer] : TSpriteFrame read GetFrames;
  property CurrentFrame : TSpriteFrame read GetCurrentFrame;
  property AnimationType : TSpriteAnimationType read fAnimationType write fAnimationType;
end;

(...)

{ TSprite Implementation }

function TSprite.GetFrames(index: integer): TSpriteFrame;
begin
  if ( index > Length(fFrames)-1 ) or ( index <; 0 ) then
     raise IndexOutOfBoundsException.Create(IntToStr(index));
  result := fFrames[ index ];
end;

function TSprite.GetFrameIndex: integer;
begin
  result := fFrameIndex;
end;

function TSprite.GetCurrentFrame: TSpriteFrame;
begin
  if ( (fFrameIndex < 0) or (fFrameIndex > High(fFrames)) ) then
    raise IndexOutOfBoundsException.Create('GetCurrentFrame');
  result := fFrames[fFrameIndex];
end;

procedure TSprite.FreeFrames;
var
  i: integer;
begin
  for i:= 0 to Length(fFrames)-1 do
    fFrames[i].Free;
  SetLength(fFrames, 0);
end;

constructor TSprite.Create;
begin
  fTexture       := TTexture.Create;
  fFrameIndex    := 0;
  fAnimationType := TSpriteAnimationType.Circular;
  fTimeSinceIndexChange := 0.0;
end;

destructor TSprite.Destroy;
begin
  FreeFrames;
  fTexture.Free;
end;

// inicializa os frames da animação, quebrando a textura em um padrão
// de pRows x pColumns (linhas x colunas)
procedure TSprite.InitFrames(const pRows, pColumns: integer);
var
  lRow, lColumn, frameW, frameH, spriteCount, i : integer;
begin
  spriteCount := pRows * pColumns;
  frameW := fTexture.W div pColumns;
  frameH := fTexture.H div pRows;

  FreeFrames;
  SetLength(fFrames, spriteCount);

  i:= 0;
  for lRow := 0 to pRows-1 do
    for lColumn :=0 to pColumns-1 do
    begin
      fFrames[ i ] := TSpriteFrame.Create;
      fFrames[ i ].TimeSpan   := DEFAULT_FRAME_DELAY;
      fFrames[ i ].Rect.w := frameW;
      fFrames[ i ].Rect.h := frameH;
      fFrames[ i ].Rect.x := lColumn * frameW;
      fFrames[ i ].Rect.y := lRow * frameH;
      inc( i );
    end;
  fFrameIndex := 0;
  inherited;
end;

//atualiza o estado da animação de acordo com a variação de tempo
//desde a última chamada
procedure TSprite.Update(const deltaTime: real);
var
  frameCount : UInt32;
  elapsedMS  : UInt32;
begin
  elapsedMS := trunc(( fTimeSinceIndexChange + deltaTime ) * MSecsPerSec );
  if ( elapsedMS >= fFrames[ fFrameIndex ].TimeSpan ) then
  begin
    frameCount := elapsedMS div fFrames[ fFrameIndex ].TimeSpan;
    AdvanceFrames( frameCount );
    fTimeSinceIndexChange := 0;
  end
  else
    fTimeSinceIndexChange := fTimeSinceIndexChange + deltaTime;
end;

procedure TSprite.AdvanceFrames(count: integer);
var
  framesLength : integer;
begin
  framesLength := Length(fFrames);
  case fAnimationType of
   TSpriteAnimationType.NoLoop:
     begin
       inc(fFrameIndex, count);
       if fFrameIndex > framesLength-1 then
          fFrameIndex := framesLength-1
       else
         if (fFrameIndex < 0) then
             fFrameIndex:= 0;
     end;

   TSpriteAnimationType.Circular :
     begin
       fFrameIndex := (fFrameIndex + count) mod framesLength;
     end;
  end;

end;

Apesar do tamanho da classe, o código de TSprite é muito simples e dispensa maiores explicações exceto, talvez, pelo método: TSprite.Update.

As animações, como sabemos, são implementadas em computação gráfica como a mudança de estado em função do tempo ou seja, para cada ponto no tempo, a contar do início do programa, alteramos a variáveis de todos os objetos para refletir seu estado naquele momento. Para que isto funcione, precisamos contar a passagem do tempo e atualizar nossos objetos periodicamente. Este é o intuito de TSprite.Update. Ele recebe em deltaTime, a quantidade de tempo decorrida desde a última atualização. Este tempo é passado em frações de segundos.

Para implementar a contagem do tempo, vamos alterar nosso game loop e adicionar um novo método em TGame.

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) / 1000);
    lastTime  := thisTime;

    HandleEvents;
    Update( deltaTime );
    Render;

    SDL_Delay(1);
  end;
end;

(...)

procedure TGame.Update(const deltaTime : real ) ;
var
  i: integer;
begin
  for i:= 0 to High(fEnemies) do
      fEnemies[i].Update(deltaTime);
end;


A função SDL_GetTicks fornece a chave para a lógica de contagem do tempo decorrido entre cada iteração do loop. Ela retorna a quantidade de milisegundos desde a inicialização do SDL. Com esta informação, calculamos a o intervalo detaTime com uma subtração ( thisTime - lastTime ) e dividimos o resultado por 1000 para converter o valor para a escala dos segundos. Feito isto, sabemos o quanto devemos avançar o estado dos objetos na linha do tempo e a função TGame.Update é responsável por isto, iterando por todos os objetos do jogo e chamando a versão adequada de TGameObject.Update para cada um ( TSprite.Update, no caso dos sprites explicados nos parágrafos anteriores ).

Ao final do loop, note a chamada de SDL_Delay(1). Ela faz com que o programa aguarde 1ms antes de prosseguir para garantir que o intervalo entre as chamadas sempre seja maior 0ms, evitando assim, uma divisão por zero no nosso cálculo e, de quebra, dando um tempinho para o processado, evitando um consumo desnecessariamente alto de cpu.

Vamos seguir alterando as classes do game para que todo TGameObject tenha um método Update, para que TEnemy possua um Sprite e para que TEnemyA saiba desenhar a si mesmo como um sprite animado.

TGameObject = class
strict protected
  fRenderer : PSDL_Renderer;
public
  Position : TPoint;
  constructor Create( const aRenderer: PSDL_Renderer ); virtual;
  procedure Update(const deltaTime : real ); virtual; abstract;
  procedure Draw; virtual; abstract;
end;

TEnemy = class( TGameObject )
private
  fSprite : TSprite;
public
  HP : integer;
  constructor Create( const aRenderer: PSDL_Renderer ); override;
  destructor Destroy; override;
  procedure Update(const deltaTime : real); override;

  property Sprite: TSprite read fSprite;
end;     

(...)

{ TEnemy Implementation }

constructor TEnemy.Create(const aRenderer: PSDL_Renderer);
begin
  inherited Create(aRenderer);
  fSprite := TSprite.Create;
end;

destructor TEnemy.Destroy;
begin
  fSprite.Free;
  inherited Destroy;
end;

procedure TEnemy.Update(const deltaTime : real);
begin
  Sprite.Update(deltaTime);
end;

(...)

procedure TEnemyA.Draw;
var
  source, destination : TSDL_Rect;
begin
  if ( HP > 0 ) and ( fSprite.Texture.Data <> nil ) then
  begin
    source := Sprite.CurrentFrame.Rect;

    destination.x := Round(self.Position.X);
    destination.y := Round(self.Position.Y);
    destination.w := 16;
    destination.h := 16;

    SDL_RenderCopy( fRenderer, fSprite.Texture.Data, @source, @destination) ;
  end;

Bingo!

Neste ponto o game deve estar executando sem erros e a animação dos inimigos sendo exibida. Caso tenha dificuldades para fazer o game compilar, faça o download do código-fonte e use-o como referência. É muito importante entender o que fizemos até aqui antes de seguir para a próxima seção.


Input de Teclado ( movendo e atirando )


Já implementamos a leitura do teclado para usarmos tecla ESC para fechar a janela do game. Vamos agora refinar o método TGame.HandleEvents para movimentar o jogardor pela tela usando as setas do teclado ou as teclas A e D, numa versão simplificada do esquema de movimentação WASD. Mas antes de seguir, precisamos de um objeto para representar o jogador, capaz de mover-se pela tela para a esquerda ou direita, em resposta aos inputs do jogador.

Observe a listagem abaixo e, depois de estudá-la um pouco, implemente-a em seu projeto.

TGameObjectNotifyEvent = procedure(Sender: TGameObject) of object;

TPlayerInput = (
  Left,
  Right,
  Shot
);

TPlayer = class( TGameObject )
private
const
  DEFAULT_SPEED    = 200.0;
  DEFAULT_COOLDOWN = 500;
var
  fSpeed    : real;
  fSprite   : TSprite;
  fCooldown : integer;
  fCooldownCounter : integer;
  fInput    : array[0..2] of boolean;
  fOnShotTriggered : TGameObjectNotifyEvent;
  function GetInput(index: integer): boolean;
  procedure SetInput(index: integer; AValue: boolean);
public
  constructor Create(const aRenderer: PSDL_Renderer); override;
  destructor Destroy; override;
  procedure Draw; override;
  procedure Update(const deltaTime : real); override;

  property Input[index: integer] : boolean read GetInput write SetInput;
  property Sprite: TSprite read fSprite;
  property Speed : real read fSpeed write fSpeed;  //em pixels por segund
  property Cooldown: integer read fCooldown write fCooldown;
  property CooldownCounter: integer read fCooldownCounter;

  property OnShotTriggered : TGameObjectNotifyEvent read fOnShotTriggered write fOnShotTriggered;
end;

(...)

{ TPlayer Implementation }

{ TPlayer }

function TPlayer.GetInput(index: integer): boolean;
begin
  if index > Length(fInput)-1 then
    raise IndexOutOfBoundsException.CreateFmt('TPlayer.GetInput(%d)', [index]);
  result := fInput[index];
end;

procedure TPlayer.SetInput(index: integer; AValue: boolean);
begin
  if index > Length(fInput)-1 then
    raise IndexOutOfBoundsException.CreateFmt('TPlayer.SetInput(%d)', [index]);
  fInput[index] := AValue;
end;

constructor TPlayer.Create(const aRenderer: PSDL_Renderer);
var
  i : integer;
begin
  inherited Create(aRenderer);
  fSprite   := TSprite.Create;
  fSpeed    := DEFAULT_SPEED;
  fCooldown := DEFAULT_COOLDOWN;
  fCooldownCounter:= 0;

  for i :=0 to High(fInput) do
     fInput[i] := false;
end;

destructor TPlayer.Destroy;
begin
  fSprite.Free;
  inherited Destroy;
end;

procedure TPlayer.Draw;
var
  source, destination : TSDL_Rect;
begin
  if ( fSprite.Texture.Data <> nil ) then
  begin
    source := Sprite.CurrentFrame.Rect;

    destination.x := Round(self.Position.X);
    destination.y := Round(self.Position.Y);
    destination.w := 26;
    destination.h := 16;

    SDL_RenderCopy( fRenderer, fSprite.Texture.Data, @source, @destination) ;
  end;
end;

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

  if fInput[Ord(TPlayerInput.Right)] then
     Position.X:= Position.X + ( fSpeed * deltaTime );

  if fCooldownCounter > 0 then
     dec(fCooldownCounter, round(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;

Note que, por especializar TGameObject, TPlayer se parece muito com TEnemy, o que é ótimo pois nos ajuda a compreender seu código mais rapidamente. Veja também como TPlayer sabe como mover-se dentro do método Update: ele mantém, em um array, um valor booleano para cada entrada possível ( Left, Right e Shot ) e, caso estes indiquem que há um movimento a ser atualizado naquele instante, a posição é atualizada multiplicando a velocidade pela variação de tempo ( fSpeed * deltaTime ). Se um tiro deve ser disparado, um evento é chamado, delegando a responsabilidade para qualquer um que implementá-lo.

Agora, podemos alterar TGame.HandleEvents para informar fPlayer o estado dos inputs.

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;

          SDLK_LEFT, SDLK_A  :
            fPlayer.Input[Ord(TPlayerInput.Left)] := true;

          SDLK_RIGHT, SDLK_D :
            fPlayer.Input[Ord(TPlayerInput.Right)]:= true;

        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)] := true
        end;
  end;
end;

Antes de compilar, lembre-se declarar fPlayer dentro de TGame e de realizar as devidas inicializações e finalizações.

O que fizemos aqui foi capturar o pressionamento (SDL_KEYDOWN) e liberação (SDL_KEYUP) das teclas que nos interessam e atualizar os flags de input do jogador para que quando a função TPlayer.Update for chamada, ela saiba se deve mover o jogador ou criar um novo tiro.

Execute o game e veja como agora é possível mover o jogador. Altere a variável fSpeed até ficar satisfeito com o resultado. O valor da constante DEFAULT_COOLDOWN controla a frequência com que o jogador pode atirar.

Perceba que a lógica de criação do disparo do jogador não foi criada. Ao invés disto, delegamos esta responsabilidade ao evento OnShotTriggered. A razão por trás desta escolha, é que tanto o jogador quando os inimigos podem efetuar disparos e, para manter o código organizado, vamos implementar a lógica de desenho e atualização dos projéteis em um único lugar. Uma classe única que manterá todos os projéteis do jogo, a TShotList.

TShotDirection = (
  Up,
  Down
);

TShot = class ( TGameObject )
private
  fDirection : TShotDirection;
  fSpeed : real;
  fVisible : boolean;
  fSprite : TSprite;
public
  constructor Create(const aRenderer: PSDL_Renderer); override;
  destructor Destroy; override;
  procedure Draw; override;
  procedure Update(const deltaTime : real); override;

  property Sprite    : TSprite read fSprite write fSprite;
  property Direction : TShotDirection read fDirection write fDirection;
  property Speed     : real read fSpeed write fSpeed;
  property Visible   : boolean read fVisible write fVisible;
end;

TShotList = class
strict private
const
  MAX_SHOTS = 64;
var
  fShots : array[0..MAX_SHOTS-1] of TShot;
  function GetItems(index: integer): TShot;
public
  constructor Create(const aRenderer: PSDL_Renderer; aTexture: TTexture);
  destructor Destroy; override;

  procedure Update(const deltaTime : real);
  procedure Draw;
  function NextIndexAvailable: integer;

  property Items[index: integer]: TShot read GetItems; default;
end;

Definimos a classe TShot para representar um disparo. Cada disparo pode seguir, em linha reta, para cima ou para baixo (TShotDirection) a uma velocidade constante (fSpeed) e, sabe desenhar e atualizar a si mesma. Veja sua implementação.

{ TShot }

constructor TShot.Create(const aRenderer: PSDL_Renderer);
begin
  inherited Create(aRenderer);
  fSpeed     := 300.0;
  fDirection := TShotDirection.Up;
  fVisible   := true;
  fSprite    := TSprite.Create;
end;

destructor TShot.Destroy;
begin
  fSprite.Free;
  inherited Destroy;
end;

procedure TShot.Draw;
var
  source, destination : TSDL_Rect;
begin
  if ( fSprite.Texture.Data <> nil ) and fVisible then
  begin
    source.x := 0;
    source.y := 0;
    source.w := 6;
    source.h := 12;

    destination.x := Round(self.Position.X);
    destination.y := Round(self.Position.Y);
    destination.w := 6;
    destination.h := 12;

    SDL_RenderCopy( fRenderer, fSprite.Texture.Data, @source, @destination) ;
  end;

end;

procedure TShot.Update(const deltaTime: real);
begin
  if fVisible then
  begin
    case fDirection of
      TShotDirection.Up   : Position.Y := Position.Y - (fSpeed * deltaTime );
      TShotDirection.Down : Position.Y := Position.Y + (fSpeed * deltaTime );
    end;
    fVisible := ( (Position.Y >= -(fSprite.Texture.H+1)) and (Position.Y < 600+1) ) and
                ( (Position.X >= -(fSprite.Texture.W+1)) and (Position.X < 800+1) );
  end;
end;

Mais uma vez, ela segue o padrão dos outros descendentes de TGameObject, encapsulando o conhecimento necessário para desenhar e atualizar a si mesmo. No final do método update, verificamos se depois de mover-se o projétil continua dentro da área visível do jogo e ajusta sua visibilidade de acordo.

A implementação de TShotList, logo abaixo, também é simples. Mantemos um array de TShot inicializado e vamos reutilizar as instâncias invisíveis para representar os tiros que forem necessários. Caso o seja solicitado um novo índice e não houver nenhum disponível, a função NextIndexAvailable retorna -1, indicando neste instante, não há mais tiros disponíveis, efetivamente limitando a quantidade de projéteis que podem estar visíveis na tela em um dado instante do gameplay, baseando-se no valor da constante MAX_SHOTS.

{ TShotList }

function TShotList.GetItems(index: integer): TShot;
begin
  if index > High(fShots) then
    raise IndexOutOfBoundsException.CreateFmt('TShotList.GetItems(%d)', [index]);
  result := fShots[index];
end;

constructor TShotList.Create(const aRenderer: PSDL_Renderer; aTexture: TTexture);
var
  i : integer;
begin
  for i:= 0 to MAX_SHOTS-1 do
  begin
     fShots[i] := TShot.Create( aRenderer );
     fShots[i].Visible:= false;
     fShots[i].Position.X := - 100;
     fShots[i].Position.Y := - 100;
     fShots[i].Speed := 250; //pixels por segundo
     fShots[i].Sprite.Texture.Assign(aTexture);
     fShots[i].Sprite.InitFrames(1,1);
  end;
end;

destructor TShotList.Destroy;
var
  i: integer;
begin
  for i:= 0 to MAX_SHOTS-1 do;
    fShots[i].Free;
end;

procedure TShotList.Update(const deltaTime: real);
var
  i: integer;
begin
  for i:= 0 to MAX_SHOTS-1 do
    fShots[i].Update(deltaTime);
  inherited;
end;

procedure TShotList.Draw;
var
  i: integer;
begin
  for i:= 0 to MAX_SHOTS-1 do
    fShots[i].Draw;
end;

function TShotList.NextIndexAvailable: integer;
var
  i: integer;
begin
  result := -1;
  for i:= 0 to MAX_SHOTS-1 do
    if not fShots[i].Visible then
    begin
      result := i;
      break;
    end;
end;


Com isto, finalizamos, por hora, os controles do jogador.
Ainda há o que melhorar. Veja, por exemplo, que é possível movê-lo para fora da área visível e que manter pressionada a barra de espaço sem mover a nave fará com que vários tiros consecutivos sejam criados. Esta é uma ótima oportunidade de exercita seu conhecimento até aqui e implementar estas correções por conta própria. Não se preocupe se tiver dificuldades, voltaremos a estas rotinas no futuro.



Suporte a Joysticks


Game rodando com suporte a joysticks
Neste ponto, seu game deve estar executando sem problemas, com as animações dos inimigos rodando corretamente (muito embora eles ainda não se movam) e o jogador se movendo e atirando. É importando garantir que tudo está funcionando como o esperado porque vamos inicializar mais um subsistema da SDL e não queremos adicionar um novo ponto de falha em um código já quebrado.

Aviso dado, vamos em frente.

Para utilizar um joystick com a SDL é preciso inicializar o subsistema de controles através de uma chamada a SDL_Init, passado o flag SDL_INIT_JOYSTICK como parâmetro e depois adquirir um ponteiro para o dispositivo controlador através da função SDL_JoystickOpen.

Começaremos, então, alterando o código de inicialização do game para incluir SDL_INIT_JOYSTICK nos flags.

procedure 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,
                             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 );

  LoadTextures;
  CreateGameObjects;
end;


Agora podemos abrir um joystick para leitura.
Por questão de segurança, vamos sempre checar se há um joystick disponível antes de tentar utilizá-lo e se não houver mais (um controle pode ser desconectado no meio do jogo, por exemplo) fechamos seu manipulador. Faremos isto em uma nova rotina que incluiremos no game loop.
Crie uma nova variável privada em TGame, chame-a de fJoystick e defina seu tipo como PSDL_Joystick, um ponteiro para um SDL_Joystick e implemente o código abaixo.

procedure TGame.CheckDevices;
var
  numJoysticks: SInt32;
begin
  numJoysticks:= SDL_NumJoysticks;
  if (numJoysticks > 0) then
  begin
     if (fJoystick = nil) then
     begin
        fJoystick:= SDL_JoystickOpen(0);
        exit;
     end;
  end
  else
    if fJoystick <> nil then
    begin
       SDL_JoystickClose(fJoystick);
       fJoystick := nil;
       exit;
    end;
end;

(...)

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; //atualizamos o status do joystick no começo de cada iteração
    HandleEvents;
    Update( deltaTime );
    Render;

    SDL_Delay(1);
  end;
end;

Com isto, é seguro plugar ou remover um joystick durante a partida. O jogo sempre saberá inicializar e liberar os recursos necessários em cada uma das situações.

O SDL notifica os eventos associados aos joysticks de maneira idêntica ao que faz com os eventos de teclado. Para cada evento ocorrido no dispositivo, a biblioteca adiciona uma mensagem na pilha e nós podemos capturá-las facilmente dentro de nosso TGame.HandleEvents. Observe os identificadores no padrão SDL_JOY* no case principal da função.

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;

          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;
        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
             //X axis motion
             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 : begin
   fPlayer.Input[Ord(TPlayerInput.Shot)] := false;
              end;
        end;
      SDL_JOYBUTTONDOWN :
        case  event.jbutton.button of
          0, 1, 2, 3 : begin
   fPlayer.Input[Ord(TPlayerInput.Shot)] := true;
              end;
        end;
    end;
  end;
end;


Bingo! Plugue o joystick e verá que pode controlar a nave com os controles direcionais e atirar utilizando os botões.

O eventos de navegação direcional horizontal são reportados como um movimento no eixo X utilizando a cosnstante SDL_JOYAXISMOTION. Se o valor lido for maior que 0, significa que houve um pressionamento na seta para a direita, se for menor que 0, a seta esquerda do joystick foi pressionada.

A mesma lógica é utilizada para reportar o pressionamento (SDL_JOYBUTTONUP) e liberação (SDL_JOYBUTTONDOWN) dos botões. Como existem inúmeros modelos de controladores de jogo, cada um com um número de diferente de botões disponíveis, capturamos os eventos dos 4 primeiros para garantir que você possa atirar com o botão de ação que achar mais confortável.

Como um bônus e para ajudar a debugar o código referente aos inputs de josysticks, criamos uma função que vai desenhar um led vermelho na tela quando não houver nenhum joystics disponível e um led verde quando o SDL detectar a presença de pelo menos 1 controle.

procedure TGame.DrawDebugInfo;
var
  source, dest : TSDL_Rect;
begin
  //desenha o led do sensor do controle
  source.y := 0;
  source.w := 16;
  source.h := 16;

  if SDL_NumJoysticks > 0 then
    source.x := 0
  else
    source.x := 16;

  dest.x := 10;
  dest.y := 10;
  dest.w := 16;
  dest.h := 16;

  SDL_RenderCopy( fRenderer, fTextures[Ord(TSpriteKind.Leds)].Data, @source, @dest);
end;

(...)

procedure TGame.Render;
begin
  SDL_SetRenderDrawColor( fRenderer, 0, 0, 0, SDL_ALPHA_OPAQUE );
  SDL_RenderClear( fRenderer );

  DrawGameObjects;
  DrawDebugInfo;  //vamos desenhar o "sensor" de joystick

  SDL_RenderPresent( fRenderer );
  fFrameCounter.Increment;
end;


Muito código foi escrito neste artigo, mas se você acompanhou com cuidado os comentários e explicações, deverá conseguir entender tudo o que fizemos. Se tiver dúvidas, críticas ou sugestões, sinta-se à vontade para utilizar a seção de comentários logo abaixo e se você chegou até aqui, meus parabéns pela dedicação! Posso garantir que a maior parte do "trabalho" sujo já foi feita e que o jogo tomará forma num ritmo cada vez maior nos próximos artigos da série.

Mais uma vez, obrigado pelo seu tempo e até a próxima!


Links