Criando um Game Completo - Parte 6.1

Bem vindo à parte 6.1 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 joysticks e uma tabela de scores online! Tudo isto compilando em Delphi e Lazarus.

Neste post, vamos complementar as melhorias introduzidas no texto anterior e criar um efeito de estrelas para deixar o plano de fundo do jogo mais interessante. A imagem ao lado mostra como ficará o visual das animações na cena do gameplay no final do processo.

Ajustes Visuais - Modulação de Cores


A primeira coisa que podemos melhorar no sistema de partículas, é fazer com que elas sejam criadas com as mesmas cores do inimigo que acabou de ser atingido, tornando assim, o efeito mais convincente (até agora todas as partículas estão sendo criadas com a cor default).

Temos 3 tipos de inimigos na cena. O primeiro, mais fraco ( TEnemyA ), posicionado na primeira linha de tiro, só suporta um único tiro antes de ser destruído. O segundo ( TEnemyB ), posicionado na linha central da formação inimiga, suporta 2 impactos antes de morrer e fica vermelho quando atingido. E, por fim, temos o inimigo posicionado nas linhas do topo ( TEnemyC ) que, tendo 3 pontos de vida, suporta dois hits antes de explodir, mudando de cor de acordo com a vida restante.

Esta lógica está implementada, de maneira global, em TEnemy.Draw. Veja:

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;

Isto tem funcionado bem. Chamamos SDL_SetTextureColorMod, para modular a cor da textura do inimigo, fazendo com que ele seja renderizado com as cores moduladas para uma cor diferente da original dependo do tipo e da quantidade de vida de cada inimigo. Como dito, isto funciona, mas por estar hardcoded dentro desta função, a rotina que irá instanciar as partículas durante a explosão não tem como saber a cor do inimigo para copiá-la

Vamos usar um pouco de orientação à objetos ao nosso favor para resolver a questão.
Primeiro, vamos introduzir um método virtual na superclasse TEnemy para devolver a cor de modulação. Depois, cada descendente vai implementar sua própria versão, sobrescrevendo o método e, por fim, vamos publicar o método como uma propriedade na classe base. Veja:

  TEnemy = class( TGameObject )
  protected
    function GetColorModulation: TSDL_Color; virtual;
  public
    property ColorModulation : TSDL_Color read GetColorModulation;
  end;

  (* TEnemyA vai utilizar versão default do método GetColorModulation
     então não é preciso alterá-la *)
  
  class( TEnemyB )
  protected
    // sobrescreveremos a função na classe base
    function GetColorModulation: TSDL_Color; override;
  end;


  TEnemyC = class( TEnemy )
  protected
    // sobrescreveremos a função na classe base
    function GetColorModulation: TSDL_Color; override;
  end;


{ implementation }

function TEnemy.GetColorModulation: TSDL_Color;
begin
  result.r := 255;
  result.g := 255;
  result.b := 255;
  result.a := 255;
end;

{...}

function TEnemyB.GetColorModulation: TSDL_Color;
begin
  result.g := 0;
  result.b := 0;
  case HP of
    1,0 : result.r := 200;
    else
      result := inherited GetColorModulation;
  end;
end;

{...}

function TEnemyC.GetColorModulation: TSDL_Color;
begin
  result.g := 0;
  result.b := 0;
  case HP of
    2 : result.r := 200;
    1,0 : result.r := 255;
    else
      result := inherited GetColorModulation;
  end;
end;

Com estas alterações, podemos reescrever o método TEnemy.Draw, simplificando-o.

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
    SDL_SetTextureColorMod( fSprite.Texture.Data,
      Self.ColorModulation.r,
      Self.ColorModulation.g,
      Self.ColorModulation.b);
    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;

Agora que cada inimigo sabe informar os parâmetros da sua modulação de cor, podemos utilizar esta informação na hora de instanciar um novo emissor de partículas para os detritos explosão. Na unit que define a cena ( scnGameplay.pas ), vamos alterar o método SpawnNewSparkAt

function TGamePlayScene.SpawnNewSparkAt(enemy: TEnemy): TEmitter;
begin
  result := TEmitterFactory.NewSmokeOneShot;
  result.Bounds.X  := round((enemy.Position.X));
  result.Bounds.Y  := round(enemy.Position.Y);
  result.Bounds.W  := round(enemy.SpriteRect.w);
  result.Bounds.H  := round(enemy.SpriteRect.h);
  result.Angle.Min := 0;
  result.Angle.Max := 380;
  result.Gravity.X := 0;
  result.Gravity.Y := 5;
  // quanto mais dano, maior a quantidade de detritos
  case enemy.HP of
    0 : result.MaxCount  := RandomRange(60, 80);
    1 : result.MaxCount  := RandomRange(8, 20);
    2 : result.MaxCount  := RandomRange(3, 8);
  end;
  // dependendo do inimigo, a área de detritos é maior ou menor
  if enemy is TEnemyB then
    result.MaxCount := result.MaxCount + 20
  else
  if enemy is TEnemyC then
  begin
    result.MaxCount := result.MaxCount + 50;
    result.Bounds.X := result.Bounds.X - 5;
    result.Bounds.Y := result.Bounds.Y + 5;
    result.Bounds.W := result.Bounds.W + 5;
    result.Bounds.H := result.Bounds.H + 5;
  end;
  result.Color := enemy.ColorModulation;
  result.Start;
end;

Perfeito! Os detritos agora serão criados com a cor do inimigo atingido.
Uma última melhoria que podemos implementar neste sistema é fazer com que detrito sejam lançados ao espaço cada vez que um inimigo for atingido e não somente no momento de sua morte e explosão. Para isto, devemos alterar o método doOnShotCollided da cena do gameplay.

procedure TGamePlayScene.doOnShotCollided(Sender, Suspect: TGameObject;
  var StopChecking: boolean);
var
  shot       : TShot;
  enemy      : TEnemy;
  explostion : TExplosion;
  engine     : TEngine;
  smoke      : TEmitter;
begin
  engine := TEngine.GetInstance;
  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 );
      engine.Sounds.Play(sndEnemyHit);
      { vamos criar um emissor a cada hit no inimigo! }
      fSparks.Add(SpawnNewSparkAt(enemy)); 
      if enemy.Alive then
         fPlayer.Score :=  fPlayer.Score + 10
      else
        begin
         fPlayer.Score :=  fPlayer.Score + 100;
         explostion := TExplosion.Create();
         explostion.Sprite.Texture.Assign(engine.Textures[Ord(TSpriteKind.Explosion)]);
         explostion.Sprite.InitFrames(1,1);
         explostion.Position.Assign(enemy.Position);
         fExplosions.Add(explostion);
        end;
      shot.Visible := false;
      shot.Active  := false;
      shot.StopEmitSmoke;
      StopChecking := true;
      exit;
    end;

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

Veja como ficou.
Bem melhor, não?

Partículas sendo criadas, a cada hit, em função da cor e do tipo do inimigo atingido



Ajustes Visuais - Background


O toque final para a cena de gameplay ficar digna de ser vista, é dar-lhe um plano de fundo.

Como a ação ocorre no espaço, nada mais natural do que criarmos um fundo estrelado. Para tanto vamos definir uma classe TStar e uma classe TStarField que irá gerenciar (manter, desenhar e atualizar) um array de estrelas;

type
  TStar = class
  private
    fPosition : TVector3D;
    fRadius   : Real;
    fLife     : Real;
    fStartLife: Real;
  public
    constructor Create;
    destructor Destroy;
    property Position : TVector3D read fPosition;
    property Radius   : Real read fRadius write fRadius;
    property Life     : Real read fLife write fLife;
    property StartLife: Real read fStartLife write fStartLife;
  end;

  TStarField = class( TInterfacedObject, IUpdatable, IDrawable )
  private
    const
      STARS_COUNT = 200;
    var
      fStars : array[0..STARS_COUNT] of TStar;
    procedure RandomizeStarts;
    procedure SpawnNewStar(i: integer);
  public
    constructor Create;
    destructor Destroy;
    procedure Update(const deltaTime : real );
    procedure Draw;
  end;

Cada estrela terá uma posição no plano 2d, mas vamos simular 3 camadas de estrelas utilizando o eixo Z, por isto fPosition é um TVector3D. Cada estrela nascerá em um ponto aleatório da tela, com um tempo de vida aleatório e irá se apagando, lentamente, até sumir para, em seguida, surgir em um novo ponto.

O código abaixo mostra a implementação destas idéias em TStarField. Se você vem acompanhando os textos da série, deve ser fácil entendê-lo. O último parâmetro do SDL_SetRenderDrawColor dentro de TStarField.Draw, entretanto, merece um explicação. Ele é o canal alpha da cor da estrela e está sendo desenhada e, para que o fundo não brigue os os objetos do primeiro plano, definimos que o valor máximo do alpha de uma estrela será de somente 150 e que ele irá diminuir de acordo com sua vida, ficando totalmente invisível quando a estrela desaparecer.


constructor TStarField.Create;
var
  i : integer;
begin
  inherited;
  for i := 0 to Pred(STARS_COUNT) do
    fStars[i] := TStar.Create;
  RandomizeStarts;
end;

destructor TStarField.Destroy;
var
  i : integer;
begin
  for i := 0 to Pred(STARS_COUNT) do
  begin
    fStars[i].Free;
    fStars[i] := nil;
  end;
end;

procedure TStarField.Draw;
const
  LayerColors: array[0..2,0..2] of byte = (
      ($99,$99,$99),
      (111,100,255),
      (6,00,171));
var
  i : integer;
  rect    : TSDL_Rect;
  renderer: PSDL_Renderer;
begin
  renderer := TEngine.GetInstance.Renderer;
  for i:=0 to Pred(STARS_COUNT) do begin
    rect.x := trunc( fStars[i].Position.X - fStars[i].Radius);
    rect.y := trunc( fStars[i].Position.Y - fStars[i].Radius);
    rect.w := trunc(2 * fStars[i].Radius);
    rect.h := rect.w;
    SDL_SetRenderDrawColor(renderer,
      LayerColors[Trunc(fStars[i].Position.Z), 0],
      LayerColors[Trunc(fStars[i].Position.Z), 1],
      LayerColors[Trunc(fStars[i].Position.Z), 2],
      Byte( Round(150* (fStars[i].Life / fStars[i].StartLife))));

    SDL_RenderFillRect(renderer, @rect);
  end;
end;

procedure TStarField.RandomizeStarts;
var
  i : integer;
begin
  for i := 0 to Pred(STARS_COUNT) do
    SpawnNewStar(i);
end;

procedure TStarField.SpawnNewStar(i: integer);
begin
  fStars[i].Position.X := RandomRange(-25, TEngine.GetInstance.Window.w+25);
  fStars[i].Position.Y := RandomRange(-25, TEngine.GetInstance.Window.h+25);
  fStars[i].Position.Z := RandomRange(0, 2);
  fStars[i].Radius     := RandomRange(1, 4) / 2;
  fStars[i].StartLife  := RandomRange(1000, 8000);
  fStars[i].Life       := fStars[i].StartLife;
end;

procedure TStarField.Update(const deltaTime: real);
var
  i : integer;
begin
  for i := 0 to Pred(STARS_COUNT) do
  begin
    if fStars[i].Life > 0 then
       fStars[i].Life := fStars[i].Life - (1000*deltaTime)
    else
      SpawnNewStar(i);
  end;
end;


Projeto compilando no Delphi XE3
Pronto! Nossa tela de gameplay está concluída.

Veja ao lado um screenshot do projeto rodando com as modificações deste post, a partir da ide do Delphi e note como, mesmo com todas as partículas e processamento adicional que inserimos, a quantidade de frames por segundo continua alta (entre 250 e 260 na minha máquina). Se quiser conferir a performance, baixe o executável para windows e veja como ele se comporta em sua máquina.

Neste ponto, nosso projeto está quase concluído. Vamos partir, no próximo post, para as finalizações. Concluiremos a tela inicial, a tela de game over e iniciaremos o port e os testes do game para Linux (utilizaremos o Ubuntu).

Abraço e obrigado a todos que chegaram conosco até aqui!

Até a próxima.

Links