Criando um Game Completo - Parte 4

Game rodando em debug no Lázarus
Bem vindo à quarta 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, o projeto começou a tomar uma cara de game. Criamos o código que distribui os inimigos na tela, incluímos o tratamento de colisões entre os tiros do jogador e os alienígenas e demos os primeiros passos na construção da interface do usuário.

Vamos seguir nossa jornada adicionando algumas animações extras, fazendo os inimigos se movimentar e atirar, ampliar a lógica de colisões para detectar os danos sofridos pelo jogador e adicionar vários efeitos sonoros.

No final, como bônus, vamos adicionar a capacidade de alternar entre os modos de tela cheia e exibição em janela. Acompanhe o código no GitHub ou faça o download nos links no final do texto e, mais uma vez, mãos à obra!



Movimentação dos Inimigos


Ciclo de movimentação dos inimigos
Vamos começar implementando a movimentação dos inimigos pela tela. Assim como no Space Invaders original, os alienígenas irão se mover, ordenadamente, em um padrão pré-definido.

Do ponto de partida, cada um irá se deslocar 3 colunas para a direita e em seguida descerá o equivalente a meia linha. Deste ponto o movimento é espelhado ou seja, o inimigo segue mais 3 colunas para a esquerda e, em seguida, mais meia linha para baixo, fechando um clico completo de movimentação. Ao final de um ciclo, cada inimigo estará exatamente 1 linha abaixo da posição em que estava originalmente. A figura ao lado mostra o caminho percorrido por um inimigo durante um ciclo de movimentação.

Este tipo de ciclo pode ser modelado com facilidade como uma máquina de estados finita.
Uma máquina de estados é um modelo usado para representar programas de computador como uma máquina abstrata que possui um número de estados finitos e bem definidos e só pode estar em um destes estados por vez.

No caso da movimentação dos inimigos, podemos dizer que há quatro estados possíveis, veja:
Estado Descrição
A parado
B movendo-se para a direita
C movendo-se para baixo
D movendo-se para a esquerda

Como definimos que o movimento horizontal, seja ele para esquerda ou direita é de exatamente 3 colunas e que o movimento descendente é de meia linha, tomando nosso grid de debug como base, chegamos às seguintes definições:
Identificador Valor Descrição
OFFSET_X 3 * DEBUG_CELL_SIZE deslocamento horizontal em pixels
OFFSET_Y DEBUG_CELL_SIZE div 2 deslocamento vertical em pixels


Para ir de um estado a outro, um fato ou um evento deve ocorrer. No nosso caso, cada vez que o deslocamento total em um sentido atingir o limite imposto, a máquina responde mudando de estado. As trocas de estados nesta pequena máquina que estamos criando obedecem as seguintes regras:


# Estado atual Evento Novo estado
1 A jogo iniciado. B
2 B (deltaX >= OFFSET_X) C
3 C (deltaY >= OFFSET_Y) and (old_state = B) D
4 C (deltaY >= OFFSET_Y) and (old_state = D) B
5 D (deltaY >= OFFSET_Y) C
legenda: detltaX = deslocamento total em x, deltaY = deslocamento total em Y

Gráfico da máquina de estados
Com as regras da máquina de estados que rege o movimento dos inimigos bem entendidas, podemos pensar em sua implementação. Se você tiver um pensamento mais visual, a imagem ao lado, que mostra graficamente o fluxo da máquina correspondente aos dados desta tabela, pode ajudar a visualizar melhor o seu funcionamento.

A tabela de estados em si, pode ser mapeada para um tipo enumerado e cada inimigo saberá duas informações sobre seu movimento: o estado atual e o estado anterior que iremos armazenar em duas variáveis privadas fOldMoveDirection fMoveDirection.

O método update de TEnemy.Update será responsável por implementar as regras da máquina e fazer a mudança dos estados quando necessário

  //Estados
  TEnemyMoveDirection = (
    None,   //A
    Right,  //B
    Down,   //C
    Left    //D
  );

(...)

procedure TEnemy.Update(const deltaTime : real);
const
  OFFSET_X = 3 * DEBUG_CELL_SIZE;
  OFFSET_Y = DEBUG_CELL_SIZE div 2;
var
  currTicks : UInt32;
  deltaX : real;
  limitX : real;
  deltaY : real;
  limitY : real;

  procedure CalcXParams( aDirection : TEnemyMoveDirection ); inline;
  begin
    deltaX := Abs(Position.X - fMovementOrigin.X);
    case aDirection of
      TEnemyMoveDirection.Right : limitX := fMovementOrigin.X + OFFSET_X;
      TEnemyMoveDirection.Left  : limitX := fMovementOrigin.X - OFFSET_X;
    end;
  end;

  procedure CalcYParams; inline;
  begin
    deltaY := Abs(Position.Y - fMovementOrigin.Y);
    limitY := fMovementOrigin.Y + OFFSET_Y;
  end;

  procedure ChangeDirection( aDirection : TEnemyMoveDirection ); inline;
  begin
    fOldMoveDirection := fMoveDirection;
    fMoveDirection:= aDirection;
  end;

begin
  if Assigned( Sprite ) then
     Sprite.Update(deltaTime);

  if ( fMoveDirection <> TEnemyMoveDirection.None ) then
    begin

      case fMoveDirection of

        TEnemyMoveDirection.Left  :
          begin
             CalcXParams( TEnemyMoveDirection.Left );
             Position.X -= fSpeed * deltaTime;
             if ( Position.X < limitX ) then
                Position.X := limitX;

             if ( deltaX = OFFSET_X ) then
             begin
               fMovementOrigin := Position;
               ChangeDirection( TEnemyMoveDirection.Down );
             end;
          end;

        TEnemyMoveDirection.Right :
          begin
             CalcXParams( TEnemyMoveDirection.Right );
             Position.X += fSpeed * deltaTime;

             if ( Position.X > limitX ) then
                Position.X := limitX;

             if ( deltaX = OFFSET_X ) then
             begin
               fMovementOrigin := Position;
               ChangeDirection( TEnemyMoveDirection.Down );
             end;

          end;

        TEnemyMoveDirection.Down  :
          begin
             CalcYParams;
             Position.Y += fSpeed * deltaTime;

             if ( Position.Y > limitY ) then
                Position.Y := limitY;

             if ( deltaY = OFFSET_Y ) then
             begin
               fMovementOrigin := Position;
               if ( fOldMoveDirection = TEnemyMoveDirection.Left ) then
                  ChangeDirection( TEnemyMoveDirection.Right )
               else
                  ChangeDirection( TEnemyMoveDirection.Left );
             end;
          end;
      end;

    end;

end;
      
Depois de realizar as alterações os inimigos estarão se movimentado de acordo com o valor de fMoveDirection, e as transições da máquina de estados estarão sendo computadas em TEnemy.Update.

Ótimo. Os inimigos estão se movendo, vamos, agora, fazê-los atirar.

Tiros e Colisões


Lambra-se de como fazemos o jogador atirar? Haviam algumas limitações que impusemos de propósito para que o game fluísse bem. Definimos um intervalo entre os tiros e delegamos a criação do tiro em si a quem implementasse o evento OnShot. Faremos a mesma coisa com os inimigos, mas com duas diferenças importantes.

  1. O jogador atira em resposta a um evento do teclado ou do gamepad. Os inimigos atirarão em responsa a um teste de probabilidade que será realizado a cada 2500ms.
  2. O jogador pode atirar a qualquer momento. Um inimigo só pode atirar se não houver outros inimigos em sua linha de tiro, caso contrário, eles matariam uns aos outros.
Implementar a primeira condição é fácil. Vamos criar uma variável privada em TEnemy para armazenar o tempo que o último teste de probabilidade foi realizado e, dentro do método update, utilizaremos este valor para saber quando podemos "jogar os dados" e verificar se o inimigo irá atirar.

Chamaremos esta variável de fLastShotIteration e a inicializaremos com 0 em TEnemy.InitFields.

Veja como ficou a versão final do método.

procedure TEnemy.Update(const deltaTime : real);
const
  OFFSET_X = 3 * DEBUG_CELL_SIZE;
  OFFSET_Y = DEBUG_CELL_SIZE div 2;
var
  currTicks : UInt32;
  deltaX : real;
  limitX : real;
  deltaY : real;
  limitY : real;

  procedure CalcXParams( aDirection : TEnemyMoveDirection ); inline;
  begin
    deltaX := Abs(Position.X - fMovementOrigin.X);
    case aDirection of
      TEnemyMoveDirection.Right : limitX := fMovementOrigin.X + OFFSET_X;
      TEnemyMoveDirection.Left  : limitX := fMovementOrigin.X - OFFSET_X;
    end;
  end;

  procedure CalcYParams; inline;
  begin
    deltaY := Abs(Position.Y - fMovementOrigin.Y);
    limitY := fMovementOrigin.Y + OFFSET_Y;
  end;

  procedure ChangeDirection( aDirection : TEnemyMoveDirection ); inline;
  begin
    fOldMoveDirection := fMoveDirection;
    fMoveDirection:= aDirection;
  end;

begin
  if Assigned( Sprite ) then
     Sprite.Update(deltaTime);

  if fCanShot and Alive then
  begin
    currTicks:= SDL_GetTicks;
    if (currTicks - fLastShotIteration >= SHOT_DELAY) then
    begin
      if Random(100) <= 10 then
      begin
        if Assigned(fOnShot) then
           fOnShot(Self);
      end;
      fLastShotIteration:= currTicks;
    end;
  end;


  if ( fMoveDirection <> TEnemyMoveDirection.None ) then
    begin

      case fMoveDirection of

        TEnemyMoveDirection.Left  :
          begin
             CalcXParams( TEnemyMoveDirection.Left );
             Position.X -= fSpeed * deltaTime;
             if ( Position.X < limitX ) then
                Position.X := limitX;

             if ( deltaX = OFFSET_X ) then
             begin
               fMovementOrigin := Position;
               ChangeDirection( TEnemyMoveDirection.Down );
             end;
          end;

        TEnemyMoveDirection.Right :
          begin
             CalcXParams( TEnemyMoveDirection.Right );
             Position.X += fSpeed * deltaTime;

             if ( Position.X > limitX ) then
                Position.X := limitX;

             if ( deltaX = OFFSET_X ) then
             begin
               fMovementOrigin := Position;
               ChangeDirection( TEnemyMoveDirection.Down );
             end;

          end;

        TEnemyMoveDirection.Down  :
          begin
             CalcYParams;
             Position.Y += fSpeed * deltaTime;

             if ( Position.Y > limitY ) then
                Position.Y := limitY;

             if ( deltaY = OFFSET_Y ) then
             begin
               fMovementOrigin := Position;
               if ( fOldMoveDirection = TEnemyMoveDirection.Left ) then
                  ChangeDirection( TEnemyMoveDirection.Right )
               else
                  ChangeDirection( TEnemyMoveDirection.Left );
             end;
          end;
      end;

    end;

end;


A cada intervalo de tempo definida em SHOT_DELAY, verificamos se o inimigo pode atirar através de fCanShot e, se puder, testamos um número aleatório num range de 100 números e verificamos, em seguida, se ele é menor ou igual a 10. Isto nos diz que há 10% de chance de um inimigo vivo disparar o evento OnShot a cada SHOT_DELAYms.

Ótimo, mas como vimos acima, somente os inimigos que não possuem nenhum alienígena abaixo de si podem atirar para não correr o risco de criar fogo amigo. Como a classe TEnemy não sabe nada sobre outros inimigos, o melhor lugar para implementarmos esta lógica é em TEnemyList que mantém uma lista muito bem organizada de todos os inimigos do jogo.

procedure TEnemyList.Update(const deltaTime: real);
var
  i: integer;
  enemy : TEnemy;
  linha: integer;
begin
  inherited Update(deltaTime);

  //só pode atirar se não houver nenhum outro inimigo na linha de tiro
  for i:=0 to Pred(Self.Count) do
  begin
    enemy:= TEnemy(Self.Items[i]);
    linha := i div 20;
    enemy.CanShot := linha = 5;
    if linha < 5 then
       case linha of
         0: enemy.CanShot := (not TEnemy(Self.Items[i+20]).Alive) and
                             (not TEnemy(Self.Items[i+40]).Alive) and
                             (not TEnemy(Self.Items[i+60]).Alive) and
                             (not TEnemy(Self.Items[i+80]).Alive) and
                             (not TEnemy(Self.Items[i+100]).Alive);

         1: enemy.CanShot := (not TEnemy(Self.Items[i+20]).Alive) and
                             (not TEnemy(Self.Items[i+40]).Alive) and
                             (not TEnemy(Self.Items[i+60]).Alive) and
                             (not TEnemy(Self.Items[i+80]).Alive);

         2: enemy.CanShot := (not TEnemy(Self.Items[i+20]).Alive) and
                             (not TEnemy(Self.Items[i+40]).Alive) and
                             (not TEnemy(Self.Items[i+60]).Alive);

         3: enemy.CanShot := (not TEnemy(Self.Items[i+20]).Alive) and
                             (not TEnemy(Self.Items[i+40]).Alive);

         4: enemy.CanShot := (not TEnemy(Self.Items[i+20]).Alive) ;
       end;

  end;

end;

Já que temos 20 inimigos por linha, dispostos em um padrão retangular, se somarmos 20 ao índice de um inimigo qualquer, podemos referenciar o inimigo exatamente abaixo dele. Dependendo da linha em que estamos no grid, devemos fazer mais ou menos comparações. As naves posicionadas na sexta linha sempre podem atirar, já que são a primeira linha de ataque dos alienígenas.

Para criar o projétil basta vamos o método TGame.doOnshot para criar uma instância de TShot e configurá-la de acordo com o emissor do evento, representado pelo parâmetro Sender.

procedure TGame.doOnShot(Sender: TGameObject);

  procedure CreateShot(Position: TPoint; Direction: TShotDirection);
  var
    shot   : TShot;
  begin
    shot := TShot.Create( fRenderer );
    shot.Sprite.Texture.Assign( fTextures[ Ord(TSpriteKind.ShotA) ] );
    shot.Sprite.InitFrames( 1,1 );
    shot.Position := Position;
    shot.Position.X -= (shot.Sprite.CurrentFrame.Rect.w / 2);
    shot.OnCollided := @doOnShotCollided;
    shot.DrawMode   := GetDrawMode;
    shot.Direction:= Direction;
    fShots.Add( shot );
  end;

begin
  if (Sender is TPlayer) then
    CreateShot(TPlayer(Sender).ShotSpawnPoint, TShotDirection.Up)
  else
  if (Sender is TEnemy) then
    CreateShot(TEnemy(Sender).ShotSpawnPoint, TShotDirection.Down);
end;


O mesmo vale para as checagens de colisão. Vamos estender TGame.CheckCollision para que ele também possa detectar colisões entre o jogador e os tiros disparados pelas naves.

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
  begin
    shotList    := fShots.FilterByDirection( TShotDirection.Up );
    suspectList := fEnemies.FilterByLife( true );
    for i:=0 to Pred(shotList.Count) do
      TShot(shotList[i]).CheckCollisions( suspectList );
    shotList.Free;
    suspectList.Free;
  end;

  //check all shots going downwards against the player
  if (fShots.Count > 0) then
  begin
    shotList := fShots.FilterByDirection( TShotDirection.Down );
    for i:=0 to shotList.Count-1 do
      TShot(shotList[i]).CheckCollisions( fPlayer );

    shotList.Free;
  end;
end;



Animação de morte


Evolução da opacidade da explosão
ao longo do tempo
Ao morrer, um inimigo simplesmente some da tela, gerando um efeito visual muito pobre. Vamos melhorar isto criando uma uma pequena animação utilizando um sprite que já carregamos para memória mas ainda não utilizamos. O sprite de explosão.

A idéia aqui também não é nova vamos criar um objeto TExplosion, descendente de TGameObject e uma classe para gerenciar todas as suas instâncias. Exatamente como fizemos com os inimigos e com os tiros. Veja a implementação desas duas classes no repositório.

O que queremos, é fazer com que um sprite de explosão seja exibido no lugar do inimigo abatido. Este sprite ficará visível por um tempo e depois começará a sumir gradualmente até desaparecer por completo.

Duas constantes governam este comportamento. LIFE_TIME define o tempo total em que a explosão será visível e START_FADE define em que ponto dentro de LIFE_TIME a explosão começará a esmaecer. A opacidade e a visibilidade do sprite são calculadas em TExplosion.Update. O gráfico acima mostra em detalhes a evolução de fOpacity em função do tempo.

procedure TExplosion.Update(const deltaTime: real);
var
  elapsed: UInt32;
  opacity : extended;
  fadeTime: integer;
begin
  if fVisible then
  begin
    elapsed := SDL_GetTicks - fCreatedTicks;
    if elapsed > START_FADE then
    begin
      elapsed -= START_FADE;
      fadeTime:= LIFE_TIME-START_FADE;
      opacity := 255 - ((elapsed  / fadeTime) * 255 );
      opacity:= Round(opacity);
      opacity:= Max(opacity, 0);
      opacity:= Min(255, opacity);

      fOpacity := Trunc(opacity);
      fVisible := elapsed < LIFE_TIME;
    end;
  end;
end; 

Agora no manipulador do evento OnShotCollided vamos criar, de fato, a explosão.

procedure TGame.doOnShotCollided(Sender, Suspect: TGameObject; var StopChecking: boolean);
var
  shot       : TShot;
  enemy      : TEnemy;
  explostion : TExplosion;
begin
  if ( Sender is TShot )  then
  begin
    shot  := TShot(Sender);
    if (Suspect is TEnemy) and (TEnemy(Suspect).HP > 0) then
    begin
      enemy := TEnemy(Suspect);
      enemy.Hit( 1 );

      if enemy.Alive then
         Inc(fScore, 10)
      else
        begin
         Inc(fScore, 100);
         explostion := TExplosion.Create(fRenderer);
         explostion.Sprite.Texture.Assign(fTextures[Ord(TSpriteKind.Explosion)]);
         explostion.Sprite.InitFrames(1,1);
         explostion.Position := enemy.Position;
         fExplosions.Add(explostion);
        end;
      fShots.Remove( shot );
      StopChecking := true;
      exit;
    end;

   if ( Suspect is TPlayer ) then
   begin
     fPlayer.Hit( 1 );
     explostion := TExplosion.Create(fRenderer);
     explostion.Sprite.Texture.Assign(fTextures[Ord(TSpriteKind.Explosion)]);
     explostion.Sprite.InitFrames(1,1);
     explostion.Position := TPlayer(Suspect).Position;
     fExplosions.Add(explostion);
     fShots.Remove( shot );
   end;
  end;

end;

Inclua uma chamada a fShots.Update em TGame.Update e você verá que ao matar um inimigo a animação é exibida, causando uma sensação visual bem melhor.


Efeitos sonoros


O game está bem mais interessante agora. Os inimigos se movimentam e atiram, o jogador sofre danos e até temos um efeito de explosão que é exibido durante a morte de uma nave inimiga. Mas falta som!

Os sons, são parte fundamental de qualquer experiência multi mídia. E os games não são exceção. Para tocar sons em nosso game, vamos recorrer a outro subsistema da SDL. O SDL Mixer. Mais uma vez, a integração desta extensão do SDL é bastante tranquila. Baixe os binários adequados à sua plataforma, extraia as dlls no diretório .\bin do projeto, inclua a unit SDL2_mixer na uses de sdlGame.pas e estamos prontos para iniciar o subsistema de som.

Assim como fizemos ao adicionar suporte ao joystick, precisamos chamar a função SDL_Init passando um flag indicando que queremos iniciar o sistema de áudio. O flag em questão se chama SDL_INIT_AUDIO e pode ser combinado os outros flags que já usamos. Depois de informado à SDL que pretendemos utilizar o subsistema de áudio, é necessário carregar as bibilotecas. Faremos isto com uma chamada à função Mix_OpenAudio. Veja como ficou a nova versão de nossa rotina de inicialização e de finalização.

procedure TGame.Initialize;
var
  flags, result: integer;
begin
  if ( SDL_Init( SDL_INIT_VIDEO or SDL_INIT_TIMER or SDL_INIT_JOYSTICK or SDL_INIT_AUDIO  ) <> 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 {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 );

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

  result := Mix_OpenAudio(44100, MIX_DEFAULT_FORMAT, 2, 2048);
  if result < 0 then
     raise SDLMixerException.Create( Mix_GetError );

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

procedure TGame.Quit;
begin
  FreeGameObjects;
  FreeTextures;
  FreeFonts;
  FreeSounds;
  fGameText.Free;
  if fJoystick <> nil then
  begin
    SDL_JoystickClose(fJoystick);
    fJoystick:= nil;
  end;
  fFrameCounter.Free;
  SDL_DestroyRenderer(fRenderer);
  SDL_DestroyWindow(fWindow);
  IMG_Quit;
  Mix_Quit;
  SDL_Quit;
end;

Passamos SDL_INIT_AUDIO para SDL_Init e, mais abaixo, abrimos efetivamente o sistema de som através da função Mix_OpenAudio. Note que os valores que passamos aqui são todos valores padrão para reprodução de áudio e deve, portanto, ser válido para a maioria das placas de som disponíveis no mercado - incluindo as placas onboard que acompanham praticamente todos os PCs.

O primeiro parâmetro é a frequência em que os sons serão reproduzidos. O número 44100 é bem conhecido para quem trabalha com música. Esta é a frequência padrão da indústria para reproduzir áudio com qualidade de CD. Depois passamos MIX_DEFAULT_FORMAT como valor para o parâmetro de formato dos samples. Este valor, na implementação, representa samples de aúdio 16bits, um outr valor padrão no mundo da música. Em seguida temos a quantidade de canais (2 para sons estéreo) e, por fim, o tamanho do buffer de memória para cada som. Este é o único parâmetro para o qual não realmente um padrão, mas a regra, como em todos os algoritmos basedos em buffers é, quanto maior o buffer, melhor. Fique à vontade para experimentar valores diferentes.

Na listagem acima, aparecem duas funções novas. LoadSounds e FreeSounds. Elas carregam e liberam, respectivamente, nossos efeitos sonoros para um array privado em TGame. Como não queremos ficar lembrando dos índices de cada som ao longo do código, também criamos um tipo enumerado para representar cada um deles.

  //enumerado para nos ajudar a lembrar o índice dos sons dentro de nosso
  //array de chunks
  TSoundKind = (
    sndEnemyBullet,
    sndEnemyHit,
    sndPlayerBullet,
    sndPlayerHit,
    sndGamePause,
    sndGameResume,
    sndGameOver
  );


  TGame = class
  strict private
  const
    WINDOW_TITLE = 'Delphi Games - Space Invaders';
  var
    fRunning          : boolean;
    fWindow           : PSDL_Window;
    fWindowSurface    : PSDL_Surface;
    fRenderer         : PSDL_Renderer;
    fFrameCounter     : TFPSCounter;
    fTextures         : array of TTexture;
    fSounds           : array of PMix_Chunk; //PMix_Chunk armazera os bytes de um arquivo de som

(....)


procedure TGame.LoadSounds;
const
  SOUND_DIR = '.\assets\sounds\';
begin
  SetLength(fSounds, Ord(High( TSoundKind))+1);

  fSounds[ Ord(TSoundKind.sndEnemyBullet) ]  := Mix_LoadWAV(SOUND_DIR + 'EnemyBullet.wav');
  fSounds[ Ord(TSoundKind.sndEnemyHit) ]     := Mix_LoadWAV(SOUND_DIR + 'EnemyHit.wav');
  fSounds[ Ord(TSoundKind.sndPlayerBullet) ] := Mix_LoadWAV(SOUND_DIR + 'PlayerBullet.wav');
  fSounds[ Ord(TSoundKind.sndPlayerHit) ]    := Mix_LoadWAV(SOUND_DIR + 'PlayerHit.wav');
  fSounds[ Ord(TSoundKind.sndGamePause) ]    := Mix_LoadWAV(SOUND_DIR + 'GamePause.wav');
  fSounds[ Ord(TSoundKind.sndGameResume) ]   := Mix_LoadWAV(SOUND_DIR + 'GameResume.wav');
  fSounds[ Ord(TSoundKind.sndGameOver) ]     := Mix_LoadWAV(SOUND_DIR + 'GameOver.wav');
end;

procedure TGame.FreeSounds;
var
  i : integer;
begin
  for i:=Low(fSounds) to High(fSounds) do
    Mix_FreeChunk(fSounds[i]);
end;


Com os sons devidamente carregados na memória, podemos tocá-los com uma chama da Mix_PlayChannel, que aceita 3 parâmetros: o canal aonde o som será executado, o endereço dos samples do som e o número de vezes que o som será repetido (caso queiramos um loop). Tudo que precisamos fazer agora é localizar os momentos certos para tocar um ou outro efeito sonoro.

Quando um tiro for disparado, por exemplo:

procedure TGame.doOnShot(Sender: TGameObject);

  procedure CreateShot(Position: TPoint; Direction: TShotDirection);
  var
    shot   : TShot;
  begin
    shot := TShot.Create( fRenderer );
    shot.Sprite.Texture.Assign( fTextures[ Ord(TSpriteKind.ShotA) ] );
    shot.Sprite.InitFrames( 1,1 );
    shot.Position := Position;
    shot.Position.X -= (shot.Sprite.CurrentFrame.Rect.w / 2);
    shot.OnCollided := @doOnShotCollided;
    shot.DrawMode   := GetDrawMode;
    shot.Direction:= Direction;
    fShots.Add( shot );
  end;

begin
  if (Sender is TPlayer) then
  begin
    CreateShot(TPlayer(Sender).ShotSpawnPoint, TShotDirection.Up);
    Mix_Volume(1, 30);
    Mix_PlayChannel(1, fSounds[ Ord(TSoundKind.sndPlayerBullet) ], 0);
  end
  else
  if (Sender is TEnemy) then
  begin
    CreateShot(TEnemy(Sender).ShotSpawnPoint, TShotDirection.Down);
    Mix_PlayChannel(1, fSounds[ Ord(TSoundKind.sndEnemyBullet) ], 0);
  end;
end;

Ou quando um tiro colidir com o jogador ou o inimigo

procedure TGame.doOnShotCollided(Sender, Suspect: TGameObject; var StopChecking: boolean);
var
  shot       : TShot;
  enemy      : TEnemy;

  procedure CreateExplosion(Position: TPoint);
  var
    explostion : TExplosion;
  begin
    explostion := TExplosion.Create(fRenderer);
    explostion.Sprite.Texture.Assign(fTextures[Ord(TSpriteKind.Explosion)]);
    explostion.Sprite.InitFrames(1,1);
    explostion.Position := Position;
    fExplosions.Add(explostion);
  end;

begin
  if ( Sender is TShot )  then
  begin
    shot  := TShot(Sender);
    if (Suspect is TEnemy) and (TEnemy(Suspect).HP > 0) then
    begin
      enemy := TEnemy(Suspect);
      enemy.Hit( 1 );
      Mix_PlayChannel(-1, fSounds[ Ord(TSoundKind.sndEnemyHit) ], 0);

      if enemy.Alive then
         Inc(fScore, 10)
      else
        begin
         Inc(fScore, 100);
         CreateExplosion(enemy.Position);
        end;
      fShots.Remove( shot );
      StopChecking := true;
      exit;
    end;

   if ( Suspect is TPlayer ) then
   begin
     fPlayer.Hit( 1 );
     CreateExplosion(TPlayer(Suspect).Position);
     Mix_PlayChannel(-1, fSounds[ Ord(TSoundKind.sndEnemyHit) ], 0);
     fShots.Remove( shot );
   end;
  end;

end;


Simples não? Mas faz uma diferença e tanto!
Com a soma destas pequenas mudanças, o game já está bem mais interessante. Para finalizar este artigo, vamos adicionar duas novas características. Uma tela de Game Over e a capacidade de pausar/retomar o game.


Estados do Game



Game exibindo a tela de pause/resume
Até agora o jogo não pode perdido, não pode ser ganho nem pode ser pausado. Se pararmos pra pensar, veremos que estes são estados fundamentais de qualquer jogo, mas até agora só temos um: o estado em que o game se encontra quando é possível jogá-lo.

Vamos introduzir uma máquina de estados no nível de TGame para implementar ostes estados essenciais e alterar alguns métodos para serem executados ou não de acordo ela.

O método mais fácil de implementar é pause. Quando o game estiver pausado, o estado dos objetos não é atualizado ou seja, nenhum TGameObject.Update deve ser chamado, mas as rotinas de renderização continuam com seu fluxo normal. Vamos utilizar tanto a tecla quanto o botão do joystick para pausar e retomar o game.

Declare uma variável privada do tipo TGameState em TGame e inicialize-a com TGameState.Playing. Em seguida, vamos alterar o estado do jogo em resposta aos comando de pausa/retorno alterando a função  TGame.HandleEvents. Note que também vamos tocar um som em resposta a esta mudança de status.


  TGameState = (
    Playing,
    Paused,
    GameOver
  );

(...)
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
          //player controls
          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
          //player controls
          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;

          SDLK_f: ToggleFullScreen;
          SDLK_o:
            begin
              fGameState := TGameState.GameOver;
              Mix_PlayChannel(0, fSounds[ Ord(TSoundKind.sndGameOver) ], 0);
            end;
          SDLK_r: StartNewGame; //reset the game
          SDLK_RETURN :
            begin
              case fGameState of
                TGameState.Paused  :
                  begin
                    fGameState:= TGameState.Playing;
                    Mix_PlayChannel(-1, fSounds[ Ord(TSoundKind.sndGameResume) ], 0);
                  end;
                TGameState.Playing :
                  begin
                    fGameState:= TGameState.Paused;
                    Mix_PlayChannel(-1, fSounds[ Ord(TSoundKind.sndGamePause) ], 0);
                  end;
                TGameState.GameOver: StartNewGame;
              end;
            end;
        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 : fPlayer.Input[Ord(TPlayerInput.Shot)] := false;
          9: // 9 for stard button
             //http://wiki.gp2x.org/articles/s/d/l/SDL_Joystick_mapping.html
          begin
              case fGameState of
                TGameState.Paused  :
                  begin
                    fGameState:= TGameState.Playing;
                    Mix_PlayChannel(-1, fSounds[ Ord(TSoundKind.sndGameResume) ], 0);
                  end;
                TGameState.Playing :
                  begin
                    fGameState:= TGameState.Paused;
                    Mix_PlayChannel(-1, fSounds[ Ord(TSoundKind.sndGamePause) ], 0);
                  end;
                TGameState.GameOver: StartNewGame;
              end;
            end;

        end;

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


Mudamos o estado do game, mas para que o jogo respeite  esta mudança, é necessário atualizar os objetos somente quando estivermos jogando. Vamos alterar TGame.Update para refletir isto.

procedure TGame.Update(const deltaTime : real ) ;
begin
  case fGameState of
    TGameState.Playing :
      begin
        fPlayer.Update( deltaTime );
        fEnemies.Update( deltaTime );
        fShots.Update( deltaTime );
        fExplosions.Update( deltaTime );
        if ( fPlayer.Lifes <=0)  then
        begin
         fGameState := TGameState.GameOver;
         Mix_PlayChannel(-1, fSounds[ Ord(TSoundKind.sndGameOver) ], 0);
        end;
      end;
    TGameState.Paused  :
      begin

      end;

    TGameState.GameOver:
      begin

      end;
  end;
end; 

Agora o jogo realmente pára em resposta ao pressionamento da tecla e do botão .
Para ficar mais bacana, vamos esmaecer o cenário e exibir um texto por cima informado ao jogador o estado que o jogo se encontra. Vamos aproveitar e também criar a tela de game over neste passo. O método que precisamos alterar é TGame.DrawGui.

procedure TGame.DrawGUI;
var
  rect  : TSDL_Rect;
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));

  rect.x:= 0;
  rect.y:= 0;
  rect.h:= round(DEBUG_CELL_SIZE * 1.5);
  rect.w:= SCREEN_WIDTH;
  SDL_SetRenderDrawColor(fRenderer, 255, 0, 0, 80);
  SDL_RenderFillRect( fRenderer, @rect );
  fGameText.Draw( Format('SCORE %.6d', [fScore]),  290, 12, fGameFonts.GUI  );

  rect.x:= 710;
  rect.y:= 18;
  rect.h:= 2 *fPlayer.Sprite.Texture.H div 3;
  rect.w:= 2 *fPlayer.Sprite.Texture.W div 3;

  SDL_RenderCopy(fRenderer,
                   fPlayer.Sprite.Texture.Data,
                   @fPlayer.Sprite.CurrentFrame.Rect,
                   @rect);
   fGameText.Draw( Format('%.2d', [fPlayer.Lifes]),  738, 12, fGameFonts.GUI  );
  case fGameState of
    TGameState.Paused   :
      begin
        //obsfuscates the game stage
        rect.x := 0;
        rect.y := round( 1.5 * DEBUG_CELL_SIZE) +1;
        rect.h := SCREEN_HEIGHT - rect.y;
        rect.w:= SCREEN_WIDTH;
        SDL_SetRenderDrawColor(fRenderer, 0, 0, 0, 200);
        SDL_RenderFillRect( fRenderer, @rect );

        fGameText.Draw( '***[ PAUSED ]***' ,  155, SCREEN_HALF_HEIGHT-24, fGameFonts.GUI64  );
        if SDL_NumJoysticks = 0 then
           fGameText.Draw( 'press  to resume', 320, SCREEN_HALF_HEIGHT+25, fGameFonts.DebugNormal  )
        else
           fGameText.Draw( 'press  to resume', 320, SCREEN_HALF_HEIGHT+25, fGameFonts.DebugNormal  );
      end;
    TGameState.GameOver :
      begin
        //obsfuscates the game stage
        rect.x := 0;
        rect.y := round( 1.5 * DEBUG_CELL_SIZE) +1;
        rect.h := SCREEN_HEIGHT - rect.y;
        rect.w:= SCREEN_WIDTH;
        SDL_SetRenderDrawColor(fRenderer, 50, 0, 0, 200);
        SDL_RenderFillRect( fRenderer, @rect );

        fGameText.DrawModulated( '***[ GAME OVER ]***' ,  105, SCREEN_HALF_HEIGHT-24, fGameFonts.GUI64, 255,0,0  );
        if SDL_NumJoysticks = 0 then
           fGameText.Draw( 'press  to start a new game', 285, SCREEN_HALF_HEIGHT+25, fGameFonts.DebugNormal  )
        else
           fGameText.Draw( 'press  to start a new game', 285, SCREEN_HALF_HEIGHT+25, fGameFonts.DebugNormal  );

      end;
  end;

end;

Com isto concluímos as modificações planejadas para este post.

Implementamos bastante coisa até aqui e neste ponto temos um game funcional. As características básicas que se repetem em todos os games de gráficos 2d estão aí. Já sabemos como exibir sprites animados, como modelar comportamentos utilizando máquinas de estados, como exibir textos baseados em fontes True Type e como tocar sons.

Pode não parecer, mas as idéias e códigos mais avançados sobre os quais iremos nos debruçar nos próximos textos são, em sua maioria, especializações do que vimos até aqui, por isso é importante que você entenda bem o conteúdos destes quatro primeiros textos.

Para finalizar, o vídeo abaixo mostra o projeto que construímos. É possível ver a tela de pause/resume e game over, as colisões e a movimentação dos inimigos. No final, é exibido um gameplay na visão de debug, onde os limites de movimentação, os retângulos de colisão e a grade na qual a posição de todos os objetos do jogo são baseados.



Abraços, bons estudos e até a próxima.



Links