Criando um Game Completo - Parte 6

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

Já faz um tempo desde o último artigo da série, um ano, para ser exato e eu gostaria de começar pedindo desculpas por esta ausência tão longa. Estive envolvido com vários projetos simultâneos nestes últimos 12 meses, ministrando muitas aulas e trabalhando com sistemas embarcados, o que foi muito enriquecedor mas acabou me afastando um pouco aqui do blog. Mea culpa, mea culpa...

Bom, explicações dadas, vamos ao que interessa: nosso game!


Arquitetura do motor do game.
No último artigo, fizemos uma refatoração no código e dividimos o motor do game em partes menores e mais especializadas, chegando à arquitetura mostrada na imagem ao lado.

Ainda há bastante espaço para melhorá-la, mas temos, neste ponto, o suficiente para darmos uma polida nos gráficos do gameplay, criando a ilusão de um rastro de fumaça para os tiros e estilhaços das naves quando atingidas ou destruídas pelo jogador. Para implementar estes efeitos, vamos utilizar um sistema de partículas, simples mas muito poderoso!



Sistemas de Partículas

Um átomo e seus elétrons como
um sistema de partículas
O estudo das partículas e sua interação, pertence ao mundo da física. Mais precisamente, é um tópico de sistemas dinâmicos da cadeira de mecânica. Se você estiver curioso, pode ver um texto completo sobre o assunto neste link aqui ou assistir a uma vídeo aula fantástica do professor Gil da Costa Marques do curso de Engenharia da Univesp.

A computação gráfica tomou a ideia da física emprestada e simplificou-a ao ponto de ser utilizável em sistemas de simulação de tempo real (como os jogos).

Para nosso uso, porém, vamos definir um sistema de partículas como sendo um conjunto de regras simples e claras que definem o comportamento de um conjunto de pontos no plano bidimensional. Sendo assim temos:
  1. Toda partícula nasce dentro de um emissor
  2. Toda partícula possui uma função que determina seu movimento
  3. Toda partícula possui um tempo de vida finito
  4. Cada partícula do sistema pode ser representada por um ponto ou uma imagem
Com estas regras, podemos imaginar que quando o míssil de nossa nave viaja pelo espaço em direção ao seu alvo, uma certa quantidade de fumaça irá sair pela sua base ou seja, a base do míssil irá emitir a fumaça. Achamos o emissor do nosso sistema.

Quando uma partícula de fumaça é emitida, ela nasce com uma velocidade inicial e um ângulo e seu movimento começa a sofrer mediata influência da força da gravidade. Depois de emitida, a partícula de fumaça começa a ser consumida pelo ar (ou atmosfera, se preferir) até o ponto em que deixará de existir. Definimos a função de movimento e vida de nossas partículas.

E, por fim, cada partícula do sistema será representada visualmente como um retângulo colorido que irá desaparecendo com tempo até sumir completamente. Definimos nossa representação.

De posse de todas as definições, podemos implementar o sistema. Vamos começar pela classe TParticle:

  TParticle = class(TGameObject)
  strict private
    fLife         : real;
    fInitialLife  : real;
    fAngle        : real;
    fSpeed        : real;
    fColor        : TSDL_Color;
    fWidth        : integer;
    fHeight       : integer;
    fVelocity     : TVector;
    function GetAlive: boolean;
    function GetAngleInRadians: real; inline;
    function GetVelocity: TVector;
  public
    constructor Create(aPosition: TVector; life, angle, speed: real);
    destructor Destroy; override;
    procedure Update(const deltaTime: real); override;
    property Life : real read fLife write fLife;
    property InitialLife: real read fInitialLife;
    property Angle : real read fAngle write fAngle;
    property Speed: real read fSpeed write fSpeed;
    property Color: TSDL_Color read fColor write fColor;
    property Width: integer read fWidth write fWidth;
    property Height: integer read fHeight write fHeight;
    property Alive: boolean read GetAlive;
    property AngleInRadians: real read GetAngleInRadians;
    property Velocity: TVector read GetVelocity;
  end;

{ TParticle }

function TParticle.GetAngleInRadians: real;
begin
  result := fAngle * (pi / 180);
end;

destructor TParticle.Destroy;
begin
  fVelocity.Free;
  inherited;
end;

function TParticle.GetAlive: boolean;
begin
  result := fLife > 0;
end;

function TParticle.GetVelocity: TVector;
var
  radians: real;
begin
  radians := GetAngleInRadians;
  fVelocity.x := fSpeed * cos(radians);
  fVelocity.y := fSpeed * sin(radians);
  result := fVelocity;
end;

constructor TParticle.Create(aPosition: TVector; life, angle, speed: real);
begin
  inherited Create;
  Self.Position := aPosition;
  fLife:= life;
  fInitialLife := life;
  fAngle:= angle;
  fSpeed:= speed;
  fVelocity := TVector.Create;
end;

procedure TParticle.Update(const deltaTime: real);
begin
  fLife := fLife - deltaTime;
  if (fLife > 0) then
  begin
    Position.X := Position.X + (fVelocity.X * deltaTime);
    Position.Y := Position.Y + (fVelocity.Y * deltaTime);
  end;
end;


O construtor da classe mostra que, como definimos, cada partícula nascerá (ou será emitida ou será instanciada, use o termo que preferir), em uma posição, terá uma tempo de vida, um ângulo e uma velocidade de movimento e a cada iteração do game, através do método update, a vida será lentamente consumida (fLife := fLife - deltaTime) e se moverá pelo espaço.

Quem irá criar as partículas do sistema e atualizar a posição de cada uma será um emissor do tipo TEmitter:

  TEmitter = class(TInterfacedObject, IUpdatable, IDrawable)
  strict private
  var
    fParticles : TParticleList;
    fKind      : TEmitterKind;
    fBounds    : TRect;

    fTimeAccum      : Real;
    fSpawnFrequency : Real;

    fActive        : Boolean;
    fGravity       : TVector;
    fAngle         : TRangeReal;
    fSpeed         : TRangeReal;
    fWidth         : TRangeReal;
    fHeight        : TRangeReal;
    fLifeSpan      : TRangeReal;
    fEmissionRate  : Integer;
    fMaxCount      : integer;
    fRenderer      : PSDL_Renderer;
    fOnAllParticleDied : TNotifyEvent;
    procedure CreateNewParticle;
    procedure UpdateParticles(const deltaTime: real);
    procedure SpawnParticles(const deltaTime: real);
  protected
     procedure doOnAllParticleDied; virtual;
  public
    constructor Create(renderer: PSDL_Renderer);
    destructor Destroy; override;
    procedure Update(const deltaTime: real);
    procedure Draw;
    procedure Start;
    procedure Stop;
    property Particles : TParticleList read fParticles;
    property Kind      : TEmitterKind read fKind write fKind;
    property Bounds    : TRect read fBounds write fBounds;

    property Active       : boolean read fActive;
    property Angle        : TRangeReal read fAngle write fAngle;
    property Speed        : TRangeReal read fSpeed write fSpeed;
    property Width        : TRangeReal read fWidth write fWidth;
    property Height       : TRangeReal read fHeight write fHeight;
    property LifeSpan     : TRangeReal read fLifeSpan write fLifeSpan;
    property Gravity      : TVector read fGravity write fGRavity;
    property MaxCount     : integer read fMaxCount write fMaxCount;
    property EmissionRate : Integer read fEmissionRate write fEmissionRate;
    property OnAllParticleDied : TNotifyEvent read fOnAllParticleDied write fOnAllParticleDied;
  end;

{implementation}

procedure TEmitter.Draw;
var
  i: integer;
  p: TParticle;
  r: TSDL_Rect;
begin
  SDL_SetRenderDrawBlendMode(fRenderer, SDL_BLENDMODE_ADD);
  for i :=0 to fParticles.Count-1 do
  begin
    p := Particles[i];
    if p.Alive then begin
      r.x := round(p.Position.X);
      r.y := round(p.Position.Y);
      r.w := round(p.Width);
      r.h := round(p.Height);
      SDL_SetRenderDrawColor(fRenderer, p.Color.r, p.Color.g, p.Color.b, p.Color.a);
      SDL_RenderFillRect(fRenderer, PSDL_Rect(@r));
    end;
  end;
end;

procedure TEmitter.CreateNewParticle;
var
  lParticle : TParticle;
  lPosition : TVector;
  lLife     : Real;
  lAngle    : Real;
  lSpeed    : Real;
  lColor    : TSDL_Color;
begin
  lPosition := TVector.Create((fBounds.x) + Random(fBounds.w),
                              (fBounds.y) + Random(fBounds.h));

  lLife     := fLifeSpan.Min + Random(Round(fLifeSpan.Max));
  lAngle    := fAngle.Min + (random * (fAngle.Max - fAngle.Min));
  lSpeed    := fSpeed.Min + (random * (fSpeed.Max - fSpeed.Min));
  lColor.r := $FF;
  lColor.g := $FF;
  lColor.b := $AA;
  lColor.a := $FF;

  lParticle := TParticle.Create(lPosition, lLife, lAngle, lSpeed);
  lParticle.Color  := lColor;
  lParticle.Width  := Round(fWidth.Min + random * (fWidth.Max - fWidth.Min));
  lParticle.Height := Round(fHeight.Min + random * (fHeight.Max - Height.Min));
  fParticles.Add(lParticle);
end;

procedure TEmitter.Update(const deltaTime: real);
begin
  fTimeAccum := fTimeAccum + deltaTime;

  if fActive then
    SpawnParticles(deltaTime);

  UpdateParticles(deltaTime);
end;

procedure TEmitter.UpdateParticles(const deltaTime: real);
var
  i: integer;
  p: TParticle;
  color: TSDL_Color;
  listChanged: boolean;
begin
  listChanged := false;
  for i:= Particles.Count-1 downto 0 do
  begin
     p := fParticles[i];
     p.Life := p.Life - deltaTime;
     if p.Alive then begin
        p.Position.X := p.Position.X + (p.Velocity.X * deltaTime) + (fGravity.X * deltaTime);
        p.Position.Y := p.Position.Y + (p.Velocity.Y * deltaTime) + (fGravity.Y * deltaTime);

        color := p.Color;
        if p.InitialLife <= 0 then
           color.a := 0
        else
           color.a := round(255 * (p.Life / p.InitialLife));
        p.Color := color;
     end
     else begin
        fParticles.Remove(p);
        listChanged := true;
     end;
  end;
  if (listChanged and (Particles.Count = 0)) then
    doOnAllParticleDied;
end;

Com TEmitter e TParticle, estamos pontos para  criar alguns efeitos. O emissor está criando partículas aleatoriamente e atualizando-as no método UpdateParticles, onde a posição, o tempo de vida e a cor de cada partícula computado. Depois disto, o método Draw desenha as partículas vivas de acordo com o que UpdateParticles calculou.


Fumaça e Explosões


Para criar efeitos utilizando as classes introduzidas acima, basta instanciar um emissor e ir alterando os parâmetros para conseguir o resultado desejado. Veja a imagem abaixo. Temos 6 emissores configurados de maneiras distintas. Nos 4 primeiros, há uma emissão contínua de partículas. Elas morrem e são recriadas em seguida em algum ponto dentro do emissor. Há também uma gravidade puxando-as para baixo e elas possuem um tempo de vida muito curto. Vamos 'anexar' um emissor deste tipo a cada míssil lançado para criar um rasto dinâmico de fumaça.

Efeitos criados modificando os parâmetros do emissor

Agora veja os 2 últimos efeitos.

Eles são idênticos aos outros ecxeto pelo fato de suas partículas não serem afetadas pela gravidade e terem uma velocidade e um ângulo de movimento inicial aleatório. As partículas aqui são emitidas todas de uma vez e não renascem mais. Vamos instanciar uma delas quando um inimigo for destruído.

Para não ter que lembrar os parâmetros iniciais cada vez que quisermos um novo emissor, vamos criar uma classe auxiliar com dois métodos estáticos. Um para criar uma fumaça contínua e outro para criar os estilhaços.

  TEmitterFactory  = class
    class function NewSmokeOneShot: TEmitter;
    class function NewSmokeContinuous: TEmitter;
  end;
{ TEmitterFactory }

class function TEmitterFactory.NewSmokeContinuous: TEmitter;
var
 bounds : TSDL_Rect;
begin
  result := NewSmokeOneShot;
  result.Kind := ekContinuous; 
  result.LifeSpan.Min := 0.2;
  result.LifeSpan.Max := 0.5;
  result.MaxCount := 600;
  result.EmissionRate := 30;
end;

{ implementation }

class function TEmitterFactory.NewSmokeOneShot: TEmitter;
begin
  result := TEmitter.Create(TEngine.GetInstance.Renderer);
  result.Kind := ekOneShot;

  result.Bounds.x := 300;
  result.Bounds.y := 200;
  result.Bounds.w := 5;
  result.Bounds.h := 10;

  result.Width.Min := 2;
  result.Width.Max := 2;

  result.Height.Min := 1;
  result.Height.Max := 2;

  result.LifeSpan.Min := 1;
  result.LifeSpan.Max := 2;

  result.Gravity.Y := 50;
  result.Gravity.X := 0;

  result.Angle.Min := 270-30;
  result.Angle.Max := 270+30;

  result.MaxCount := 10;
end;

Agora, cara míssil ( TShot ) pode emitir um rastro de fumaça! Basta criarmos um emissor e alterarmos seu método Draw. Veja como ficou a classe depois das alterações:

  TShot = class ( TGameObject )
  strict private
    fDirection       : TShotDirection;
    fSpeed           : real;
    fShowSmoke       : boolean;
    fSmokeEmitter    : TEmitter;

    fActive: boolean;
    procedure DrawShot;
  public
    constructor Create; override;
    destructor Destroy; override;

    procedure Draw; override;
    procedure Update(const deltaTime : real); override;
    function IsInsideScreen: boolean;
    procedure StopEmitSmoke;
    procedure StartEmitSmoke;

    property Active    : boolean read fActive write fActive;
    property Direction : TShotDirection read fDirection write fDirection;
    property Speed     : real read fSpeed write fSpeed;
    property ShowSmoke : boolean read fShowSmoke write fShowSmoke;

  end;

{ implementation }

constructor TShot.Create;
begin
  inherited;
  fSpeed     := 300.0;
  fDirection := TShotDirection.Up;
  fVisible   := true;
  fShowSmoke := false;
  fSmokeEmitter     := TEmitterFactory.NewSmokeContinuous; 
  fSmokeEmitter.OnAllParticleDied := doOnAllParticleDied;
  fActive    := True;
end;


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

procedure TShot.Draw;
begin
  if fVisible then
     DrawShot;

  if fShowSmoke then
     fSmokeEmitter.Draw;
end;


procedure TShot.Update(const deltaTime: real);
var
  insideScreen: boolean;
begin
  if fActive then
  begin
    case fDirection of
      TShotDirection.Up   : fPosition.Y := fPosition.Y - (fSpeed * deltaTime);
      TShotDirection.Down : fPosition.Y := fPosition.Y + (fSpeed * deltaTime);
    end;
  end;
  fSmokeEmitter.Bounds.X := Round(fPosition.X);
  fSmokeEmitter.Bounds.W := fSprite.CurrentFrame.Rect.w;
  fSmokeEmitter.Bounds.Y := Round(fPosition.Y + fSprite.CurrentFrame.Rect.h);
  fSmokeEmitter.Update(deltaTime);

  insideScreen := isInsideScreen;
  if fVisible then
     fVisible := insideScreen;
  if (not insideScreen and fSmokeEmitter.Active) then
     StopEmitSmoke;

end;

procedure TShot.StartEmitSmoke;
begin
  fSmokeEmitter.Start;
end;

procedure TShot.StopEmitSmoke;
begin
  fSmokeEmitter.Stop;
end;

Veja como ficaram os mísseis depois da alteração. Bem mais interessante, não?

Veja que há um discreto rasto de fumaça saindo dos mísseis

Agora, como um toque final, vamos melhorar o efeito de explosão das naves atingidas, criando um emissor do tipo "OneShot" no local da explosão.

Na cena do game play, vamos adicionar um novo método para instanciar nosso emissor:

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.MaxCount  := Random(30) + 30;
  result.Angle.Min := 0;
  result.Angle.Max := 380;
  result.Gravity.X := 0;
  result.Gravity.Y := 0;
  result.Start;
end;

E, finalmente, vamos invocá-la depois de criar a explosão:

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);
      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);
         fSparks.Add(SpawnNewSparkAt(enemy));
        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;

Note que fSparks é uma nova variável privada, do tipo TEmitter que deve ser adicionada à cena.
Compare os resultados:

Versão final, com fumaça, explosão e fragmentos sendo emitidos

Bacana, não?

Com estes detalhes implementados, nosso game está mais próximo de uma versão final. Vamos, no próximo artigo, criar um efeito de estrelas se movimentado no fundo e finalizar o cenário da tela de gameplay.

Abraços e até lá.

Links