Criando jogos em Delphi – Parte IV

9 03 2009

- Animações -

    Quando várias imagens são mostradas em um curto intervalo de tempo, nosso cérebro interpreta isso como movimento. À freqüência de imagens mostradas damos o nome de FPS (Frames per Second, em inglês). Filmes normalmente utilizam 30 quadros (frames) por segundo, enquanto os jogos mais atuais utilizam 60 quadros. Note que 15 é o mínimo de quadros aceitável em uma animação, mas quanto mais quadros por segundo uma animação tiver, mais suave ela será.

    Teoria básica apresentada, vamos ao que interessa. Para montarmos uma animação no Delphi, tudo o que precisaremos é mostrar uma seqüência de imagens na tela. Mas como mostrar essa seqüência? Simples. Vamos usar variáveis para recordar qual quadro devemos mostrar na tela, e por quanto tempo ele ainda deve ser mostrado na tela.

- A classe TAnimation-

    Para controlar a animação vamos criar uma classe, nesse caso a TAnimation, que derivará apenas de TObject (como todas as classes em Delphi derivam de TObject, nós podemos deixar essa herança implícita). Essa classe precisa guardar uma matriz contendo as imagens de cada quadro, o quadro que está sendo mostrado atualmente e o tempo de espera entre um quadro e outro. Além disso, ela precisa de uma rotina que atualize a animação. Essa rotina recebe o tempo decorrido e atualiza a variável que guarda o tempo que o quadro atual foi exibido. Se esse tempo for maior do que o tempo de espera, alteramos o quadro da animação. Por fim, precisamos de uma rotina para desenhar o quadro atual na tela. Sabendo disso, podemos criar a nossa classe.

type
  //Aqui nós declaramos a classe da nossa explosão
  TAnimation = class
  private
    fFrame: Integer; //o quadro atual da animação
    fDelay: Integer; //o tempo de espera entre um quadro e outro (em ms)
    fDelayed: Double; //o tempo que o frame atual foi exibido na tela (em ms)
    fSpeed: Double; //a velocidade da animação
    fX, fY: Double; //a posição da animação no formulário
    fFrames: array of TBitmap; //os frames da animação
 
    procedure setFrame(value: Integer);
    procedure setDelayed(value: Double);
    procedure setFrameImage(Index: Integer; value: TBitmap);
    function getFrame(Index: Integer): TBitmap;
  public
    property Frame: Integer read fFrame write setFrame;
    property Delay: Integer read fDelay write fDelay;
    property Delayed: Double read fDelayed write setDelayed;
    property Speed: Double read fSpeed write fSpeed;
    property X: Double read fX write fX;
    property Y: Double read fY write fY;
    property Image[Index: Integer]: TBitmap read getFrame write SetFrameImage;
 
    constructor Create;
    destructor Destroy; override;
    //desenha o quadro atual na tela
    procedure Paint(canvas: TCanvas);
    //atualiza a animação
    procedure Update(lag: Integer);
    //seta os frames a partir de uma array de bitmaps
    procedure SetFrames(values: array of TBitmap);
  end;
 
implementation
 
// --------------------------------------------------------------------------
// TAnimation
// -----------
 
// Inicializa a classe
constructor TAnimation.Create;
begin
  inherited Create;
  fFrame:= 0;
  fDelay:= 0;
  fDelayed:= 0;
  fX:= 0;
  fY:= 0;
  fSpeed:= 1;
  setLength(fFrames, 0);
end;
 
// Destrói a instancia
destructor TAnimation.Destroy;
var i: Integer;
begin
  for I := 0 to High(fFrames) do
    FreeAndNil(fFrames[I]);
  setLength(fFrames, 0);
  FreeAndNil(fFrames);
  inherited Destroy;
end;
 
// Retorna a imagem do frame atual
// Gera um erro se o índice (Index) estiver fora dos limites da matriz de
// frames.
function TAnimation.getFrame(Index: Integer): TBitmap;
begin
  if (Index < 0) or (Index > High(fFrames))  then
    raise Exception.Create('Índice da animação fora da faixa permitida.');
  Result:= fFrames[Index];
end;
 
// Desenha o frame atual no canvas
procedure TAnimation.Paint(canvas: TCanvas);
begin
  if fFrame < 0 then
    exit;
  canvas.Draw(Round(fX), Round(fY), fFrames[fFrame]);
end;
 
// Modifica o tempo que o frame atual foi exibida
// Se este tempo for maior ou igual ao tempo que precisamos esperar, passa
// para o próximo frame.
procedure TAnimation.setDelayed(value: Double);
var incFrame: Integer;
begin
  fDelayed:= value;
  if fDelayed >= fDelay then
  begin
    incFrame := floor(fDelayed / fDelay);
    fDelayed:= floor(fDelayed) mod fDelay;
    setFrame(fFrame + incFrame);
  end;
end;
 
// Modifica o frame da animação
procedure TAnimation.setFrame(value: Integer);
begin
  fFrame:= value;
  if fFrame < 0 then
    fFrame:= 0;
  if fFrame > High(fFrames) then
  begin
    fFrame:= -1;
    fSpeed:= 0;
  end;
end;
 
// Altera a imagem de um dos frames da matriz
procedure TAnimation.setFrameImage(Index: Integer; value: TBitmap);
begin
  if (Index < 0) or (Index > High(fFrames)) then
    raise Exception.Create('Índice da animação fora da faixa permitida.');
  fFrames[Index]:= value;
end;
 
// Seta os frames da matriz pela matriz de bitmaps passada
procedure TAnimation.SetFrames(values: array of TBitmap);
var
  I: Integer;
begin
  setLength(fFrames, Length(values));
  for I := 0 to High(values) do
  begin
    fFrames[I]:= TBitmap.Create;
    fFrames[I].Assign(values[I]);
  end;
end;
 
// Atualiza a animação
procedure TAnimation.Update(lag: Integer);
begin
  setDelayed(fDelayed + (lag * fSpeed));
end;

    Nossa classe agora está criada. É uma classe que pode controlar qualquer animação usando imagens (quadros) no formato bitmap. Para esse tutorial, vamos criar um pequeno programa que mostra várias animações de explosão em lugares aleatórios da tela.

    Antes de continuar, faça o download do código fonte e imagens da animação aqui.

    Agora que você já tem as imagens, vai perceber que elas estão no formato jpeg (aliás, um formato que não deve ser usado para animações/sprites), que eu usei aqui só pra reduzir o tamanho do arquivo. =D Mas, enfim, como as imagens estão em jpeg, temos três alternativas: A primeira é converter cada imagem manualmente para bitmap, já que nossa classe só aceita esse formato. A segunda, é alterar nossa classe para funcionar com imagems em jpeg ao invés de bitmap, mas como eu disse, jpeg deve ser evitado em animações e sprites. A terceira, é criar uma função para converter as imagens de jpeg para bitmap. Vamos usar a terceira, já que eu sou um programador garoto preguiçoso demais para converter as imagens manualmente ^.-

//transforma JPEG em BMP
function jpeg2bmp(imagem: TJPEGImage): TBitmap;
 
implementation
 
function jpeg2bmp(imagem: TJPEGImage): TBitmap;
begin
  // instancia o bitmap
  result:= TBitmap.Create;
  // altera as propriedades (largura/altura) para desenhar a imagem jpeg nele
  result.Width:= imagem.Width;
  result.Height:= imagem.Height;
  // desenha a imagem jpeg no canvas
  result.Canvas.Draw(0, 0, imagem);
end;

    Agora nós temos imagens que servem como quadros em nossa classe de animação, podemos começar a criar nosso programa. Primeiro, vamos precisar de algumas variáveis para controlar as animações e, claro, nosso backbuffer.

var
  Form1: TForm1;
  // variável do backbuffer
  bbuffer: TBitmap;
 
  // array dos frames usados na animação
  // Note que essa variável global só foi adicionada para não carregar
  // a mesma animação do HD várias vezes.
  AnimFrames: array of TBitmap;
 
  // Número de frames mostrados.
  Frames: Integer;
  // Número de frames por segundo.
  FPS: Integer = -1;
  // Número de milisegundos para cálculo de FPS.
  FPSTime: Integer;
 
  // Uma lista das animações ativas
  RunningAnims: TList;
 
  // O tempo gasto na última atualização da tela
  // Necessário para que as animações rodem em uma velocidade constante,
  // independente da velocidade da máquina
  OldTime: Integer = -1;

    AnimFrames é uma variável útil, já que ela vai permitir carregar as imagens do HD no formato jpeg e guardá-las no formato bitmap, assim nós só precisamos convertê-las uma vez. Já RunningAnims é uma lista com ponteiros para instâncias das animações mostradas na tela. Enfim, nós carregamos as imagens e inicializamos essas variáveis quando o form é criado, e liberamos os recursos quando ele é destruído.

    Além disso, precisaremos de 2 objetos TTimer. Um com o intervalo de 1 milisegundo, que vai controlar a atualização das explosões, e o outro com intervalo de 100 milisegundos, que vai criar animações aleatoriamente na tela.

procedure TForm1.FormCreate(Sender: TObject);
var
  I: Integer;
  image: TJPEGImage;
begin
  randomize;
 
  // Cria o backbuffer
  bbuffer:= TBitmap.Create;
  bbuffer.Width:= 640;
  bbuffer.Height:= 480;
  bbuffer.Canvas.CopyMode:= cmSrcPaint;
  bbuffer.Canvas.Brush.Color:= clBlack;
 
  // Carrega a animação do HD
  setLength(AnimFrames, 36);
  for I := 0 to 35 do
  begin
    image:= TJPEGImage.Create;
    image.LoadFromFile('explosion/screen' + IntToStr(I+1) + '.jpg');
    AnimFrames[I]:= jpeg2bmp(image);
    FreeAndNil(image); // libera a imagem da memória
  end;
 
  // Inicializa a lista de animações
  RunningAnims:= TList.Create;
 
  // Habilita o timer
  Timer1.Enabled:= True;
  Timer2.Enabled:= True;
end;
 
// Destrói o form e libera os recursos
procedure TForm1.FormDestroy(Sender: TObject);
var i: Integer;
begin
  FreeAndNil(bbuffer);
  for I := 0 to 35 do
    FreeAndNil(AnimFrames[I]);
end;

    O modo de cópia cmSrcPaint em conjunto com o fundo preto, faz com que a cor preta das imagens desenhadas no backbuffer fique transparente.

    Agora que inicializamos tudo, falta criar as animações. Então no evento OnTimer do Timer2 vamos colocar um pequeno código que cria animações aleatórias pela tela.

// cria animações em intervalos regulares em posições aleatórias
procedure TForm1.Timer2Timer(Sender: TObject);
var anim: TAnimation;
begin
  // instancia a animação
  anim:= TAnimation.Create;
  // coloca a animação em um lugar aleatório da tela
  anim.X:= Random(ClientWidth);
  anim.Y:= Random(ClientHeight);
  // carrega os frames da matriz AnimFrames na animação
  anim.SetFrames(AnimFrames);
  // seta um tempo de espera aleatório
  anim.Delay:= random(10)+20;
  // adiciona o ponteiro da instância na lista de animações ativas
  RunningAnims.Add(anim);
end;

    Por fim, precisamos escrever o código do evento OnTimer do Timer1, que irá controlar todas as animações ativas na tela. Isso é possível pois temos uma lista de todas as animações ativas guardadas em RunningAnims, então basta percorrer esta lista e atualizar as animações nela.

// Timer que controla a atualização da tela
procedure TForm1.Timer1Timer(Sender: TObject);
var
  I: Integer;
  at: TAnimation;
  Lag: Integer;
  t: Integer;
begin
  // como o timer tem um intervalo de 1 milisegundo, esperamos que a tela
  // seja atualizada neste intervalo, mas se demorarmos mais para atualizar
  // a tela, verifica quanto tempo se passou para que a animação não seja
  // prejudicada
 
  t:= getTickCount;
  if OldTime < 0 then
    OldTime:= t;
  Lag:= (t - OldTime);
  OldTime:= t;
  if Lag < 0 then
    Lag:= 1;
  I:= 0;
 
  Inc(FPSTime, Lag);
 
  if FPSTime > 1000 then
  begin
    FPSTime:= FPSTime - 1000;
    FPS:= Frames;
    Frames:= 0;
  end;
 
  Inc(Frames);
 
  if FPS > -1 then
    caption:= 'Parte IV - Animações - FPS: ' +  IntToStr(FPS);
 
  // limpa o canvas
  bbuffer.Canvas.FillRect(Rect(0,0,640,480));
 
  // desenha todas as animações da lista
  while I < RunningAnims.Count-1 do
  begin
    at:= TAnimation(RunningAnims.Items[I]);
 
    // se a animação tiver acabado, retira ela da lista
    if at.fFrame = -1 then
    begin
      RunningAnims.Remove(at);
      FreeAndNil(at);
    end
 
    // do contrário, atualiza e desenha a animação
    else begin
      at.Update(lag);
      at.Paint(bbuffer.Canvas);
      Inc(I);
    end;
  end;
 
  // envia o backbuffer pra tela
  Canvas.Draw(0,0, bbuffer);
end;

    Para ver o efeito das animações basta rodar o programa e voílà! Você deve ver várias animações sendo criadas aleatóriamente pela tela em intervalos regulares.

- Conclusão -

    Esse foi um pequeno tutorial para desenvolvimento de jogos em Delphi, utilizando apenas a GDI do Windows. Claro, você não deve utilizar a GDI do Windows para desenvolver jogos, mas o conceito aqui se aplica a qualquer outra framework. Eu pretendo escrever mais tutoriais de GameDev em Delphi, utilizando frameworks como Asphyre e SDL (apesar de estar um pouco mais ocupado agora i.i). Até lá o(^-^)/”