Criando um Game Completo - Parte 5

Sequencia de introdução do game
Bem vindo à quinta 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!

No último artigo completamos uma versão jogável do game. Os sons, as animações o tratamento de colisões, tudo o que planejamos foi implementado com sucesso. Mas ainda falta muita coisa para o game estar, de fato, completo.

A imensa maioria do código que escrevemos está contido dentro de uma única classe: TGame. Isto vinha funcionando bem, mas conforme o game cresce, fica evidente que as classes estão acumulando responsabilidades demais. Com a necessidade de criar outras telas, é preciso isolar algumas tarefas e criar objetos mais genéricos para que possamos reutilizá-los em outras partes.

Neste post, vamos fazer uma grande refatoração no código e criar nosso próprio motor, de modo que sejamos capaz de realizar tudo o que TGame faz atualmente e começar a esconder alguns detalhes de nível mais baixo do SDL. Vamos caminhar rumo a um modelo de programação menos procedural e mais orientado a eventos, um conceito bem familiar para quem está acostumado a bibliotecas como VCL e LCL.

Espero que apreciem.

Abraços!


Engine


A classe TGame está sobrecarregada! Ela possui responsabilidades demais. Embora as coisas tenham funcionado bem até aqui, chegamos em um ponto em que uma refatoração se faz necessária.

Diagrama de classes do motor que vamos implementar
Um jogo não se resume à tela em que a ação acontece. Sempre há um lugar onde é possível ver os créditos, uma tabela de pontuação, uma introdução e algum tipo de menu que permita que o jogador navegue entre estas telas.


Se quisermos criar uma destas novas telas, será preciso replicar muita coisa que já escrevemos em TGame. Veja, esta nova tela precisaria de um loop para controlar o tempo, precisaria chamar as rotinas de render e update e precisaria capturar os eventos do SDL para funcionar. Tal como está, pouco podemos fazer além de copiar boa parte do código e colar em uma nova classe. É preciso criar um mecanismo de abstração que se responsabilize pelo loop principal e que seja capaz de processar e exibir as nossas telas além de gerenciar os sons, as texuras e as fontes. Em outras palavras, precisamos arquitetar uma camada de software que abstraia estas tarefas. É exatamente isto que faz um game engine e, agora que temos uma necessidade real, vamos implementar o nosso.

Veja o diagrama da figura acima. Estude-o por alguns minutos.




Cenas e eventos


Vamos começar pela classe TScene e TSceneManager.

A classe TScene irá encapsular uma cena no game. Cada cena saberá renderizar a si mesmo e irá se comunicar com o TEngine através de eventos.

  TScene = class
  strict private
    fName: string;
    fOnRender: TRenderEvent;
    fOnUpdate: TUpdateEvent;
    fOnKeyUp: TKeyboardEvent;
    fOnKeyDown: TKeyboardEvent;
    fOnJoyButtonUp: TJoyButtonEvent;
    fOnJoyButtonDown: TJoyButtonEvent;
    fOnJoyAxisMotion: TJoyAxisMotionEvent;
    fOnCheckCollisions: TEvent;
    fTJoyButtonEvent: TJoyButtonEvent;
    fOnQuit: TNotifyEvent;
  protected
    fQuitting : boolean;

    procedure doOnRender(renderer : PSDL_Renderer); virtual;
    procedure doOnUpdate(const deltaTime : real); virtual;
    procedure doOnKeyUp(key: TSDL_KeyCode); virtual;
    procedure doOnKeyDown(key: TSDL_KeyCode); virtual;
    procedure doOnJoyButtonUp(joystick: SInt32; button: UInt8); virtual;
    procedure doOnJoyButtonDown(joystick: SInt32; button: UInt8); virtual;
    procedure doOnJoyAxisMotion(axis: UInt8; value: SInt32); virtual;
    procedure doOnCheckCollitions; virtual;
    procedure WireUpEvents;

    procedure doLoadTextures; virtual;
    procedure doFreeTextures; virtual;
    procedure doBeforeStart; virtual;
    procedure doQuit;
  public
    constructor Create;
    destructor Destroy; override;

    procedure Start;
    procedure Stop;

    property Name: string read fName write fName;

    property OnRender: TRenderEvent read fOnRender write fOnRender;
    property OnUpdate: TUpdateEvent read fOnUpdate write fOnUpdate;
    property OnKeyDown: TKeyboardEvent read fOnKeyDown write fOnKeyDown;
    property OnKeyUp: TKeyboardEvent read fOnKeyUp write fOnKeyUp;
    property OnJoyButtonUp: TJoyButtonEvent read fOnJoyButtonUp write fOnJoyButtonUp;
    property OnJoyButtonDown: TJoyButtonEvent read fTJoyButtonEvent write fTJoyButtonEvent;
    property OnJoyAxisMotion: TJoyAxisMotionEvent read fOnJoyAxisMotion write fOnJoyAxisMotion;
    property OnCheckCollisions: TEvent read fOnCheckCollisions write fOnCheckCollisions;
    property OnQuit: TNotifyEvent read fOnQuit write fOnQuit;
  end;

Na inicialização da classe, chamamos o método WireUpEvents para ajustar os ponteiros dos eventos fazendo com que eles apontem para seu método protegido equivalente. As cenas que iremos criar mais à frente, deverão implementar suas funcionalidades sobrescrevendo estes métodos virtuais.

Já TSceneManager servirá para agrupar todas as cenas do jogo. Sua implementação é a mais simples possível: uma coleção de TScenes, alguns métodos de busca e um indexador para a cena que está sendo exibida em um determinado momento.

TGSceneList = specialize TFPGObjectList;

  { TSceneManager }

  TSceneManager = class
  strict private
    fCurrentScene: integer;
    fScenes: TGSceneList;
    function GetCurrentScene: TScene;
    procedure SetCurrentScene(AValue: TScene);
  public
    function Add(scene: TScene): integer;
    function ByName(const name: string): TScene;
    constructor Create;
    destructor Destroy; override;


    property Current: TScene read GetCurrentScene write SetCurrentScene;
  end;


Vamos em frente e, com a idéia de desacoplar os vários componentes do motor em subsistemas menores, criamos as seguintes classes:

  1. TFonts, para encapsular as fontes do game
  2. TTextManager, para desenhar textos na tela usando as fontes de TFonts
  3. TSoundManager, para organizar os sons em memória e realizar seu playback
  4. TTexrureManager, para organizar as instâncias de TTexture
Cada uma destas classes, atua como um contêiner especializado para possamos começar a separar os recursos específicos de uma cena ou jogo (imagens, sons e fontes) do código de nosso motor, permitindo criar diferentes cenas a partir do mesmo conjunto de classes.

Com os recursos devidamente isolados, podemos nos concentrar no motor e em sua lógica.

Vimos, nos artigos anteriores, que toda a lógica do jogo acontecia dentro de um loop. Iremos abstrair a lógica deste loop no método TEngine.Run e faremos com que ele execute no contexto de uma cena (fActiveScene).

procedure TEngine.Run;
var
  deltaTime : real;
  thisTime, lastTime : UInt32;
begin
  if fActiveScene = nil then
     raise EngineException.Create('There is no scene to proccess.');

  deltaTime := 0.0;
  thisTime  := 0;
  lastTime  := 0;
  fRunning  := true;
  while fRunning do
  begin
    thisTime  := SDL_GetTicks;
    deltaTime := real((thisTime - lastTime) / MSecsPerSec);
    lastTime  := thisTime;

    doCheckDevices;
    doHandleEvents;
    doUpdate(deltaTime);
    doCheckCollisions;
    doRender;

    SDL_Delay(1);
  end;
end;

Antes disto, entretanto, é necessário garantir que os subsistemas estão todos inicializados. Para tanto, criamos em TEngine.Inicialize, que recebe a resolução da tela e o título da janela como parâmetros.

procedure TEngine.Initialize(const width: integer; const height: integer;
  const title: string);
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 );

  fTitle:= title;
  fWindow := SDL_CreateWindow( PAnsiChar( fTitle ),
                               SDL_WINDOWPOS_UNDEFINED,
                               SDL_WINDOWPOS_UNDEFINED,
                               width, height,
                               SDL_WINDOW_SHOWN);
  if ( fWindow = nil ) then
     raise SDLException.Create( SDL_GetError );


  fRenderer := SDL_CreateRenderer( fWindow, -1, SDL_RENDERER_ACCELERATED);
  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 div 2, MIX_DEFAULT_FORMAT, 2, 2048);
  if result < 0 then
     raise SDLMixerException.Create( Mix_GetError );

  fText := TTextManager.Create( fRenderer );

  fFonts := TFonts.Create( fRenderer );
  fFonts.LoadFonts( FONTS_DIR );

  fSounds := TSoundManager.Create;
  fSounds.LoadSounds(SOUND_DIR);

end;

Finalmente, podemos utilizar o motor para exibir uma cena.

try
    Engine := TEngine.GetInstance;
    Engine.Initialize(800, 600, 'Window Title');
    Engine.SetActiveScene(myAwsomeScene);
    Engine.Run;
  finally
     Game.Free;
     Engine.Free;
  end;            

Supondo que myAwsomeScene seja uma instância de TScene, ela irá receber todos as mensagens do SDL que utilizamos até agora  na forma de chamadas a seus eventos equivalentes (aqueles que vimos no começo desta seção). Bacana não?

Com nosso pequeno motor finalizado, é conveniente criar um contêiner para as várias cenas que um jogo pode ter e é aí que entra a última classe do diagrama: a classe TGame.

  TGame = class
  strict private
    fSceneManager : TSceneManager;
  public
    constructor Create;
    destructor Destroy; override;

    property Scenes : TSceneManager read fSceneManager write fSceneManager;
  end; 

E como estamos criando uma versão do Space Invaders...

TSpaceInvadersGame = class(TGame)
  strict private
    fPlayer : TPlayer;
  public
    constructor Create;
    destructor Destroy; override;
  end;                                



Criando uma introdução


Muita coisa mudou e muitas coisas novas surgiram da última versão do código para esta, mas se você seguir o diagrama de classes do início do texto, não deve ser difícil acompanhar as mudanças. O que importa é que, depois desta refatoração, agora temos um pequeno motor que vai entregar os eventos de nível mais baixo para nosso TGame, nos permitindo esquecer por um tempo os detalhes dos vários subsistemas e nos concentrar em escrever uma cena por vez.

Veja o vídeo abaixo e perceba como a sequência de introdução é programada em uma única classe: TIntroScene e como, depois de carregadas as texturas, a lógica é implementada sobrescrevendo os métodos doOnRender e doOnUpdate, que o motor se encarrega de chamar no momento certo.



Quando a cena termina, o executamos o método doQuit que informa ao motor que acena terminou e este, por sua vez invoca o método doOnSceneQuit de TSpaceInvadersGame que se encarrega de carregar a próxima cena.


procedure TSpaceInvadersGame.doOnSceneQuit(sender: TObject);
var
  next : TScene;
begin
  if ( sender is TIntroScene ) then
  begin
    TIntroScene(sender).Stop;
    next := Scenes[fSceneMainMenu];
    Scenes.Current := next;
  end;

  if ( sender is TMainMenuScene ) then
  begin
     TMainMenuScene(sender).Stop;
     next := Scenes[fSceneGamePLay];
     Scenes.Current := next;
  end;

  if next <> nil then
  begin
    TEngine.GetInstance.SetActiveScene(next);
    next.Start;
  end;

end;

A lógica da introdução é bem simples.
Temos 3 estados, um para cada imagem a ser exibida, e vamos alterar entres eles após um determinado tempo. Cada uma das imagens possui 3 estágios de exibição:

  1. FADEIN
    A imagem está aparecendo.
    O alpha sai de 0 e vai subindo gradualmente até 255. A duração deste estágio é controlada pela constante LOGO_FADEIN.
  2. VISIBE
    A imagem está 100% visível.
    O alpha permanece em 255 durante LOGO_VISIBLE milissegundos.
  3. FADEOUT
    A imagem está desaparecendo.
    O alpha sai de 255 e vai diminuindo gradualmente até chegar em 0. 
    A duração deste estágio é controlada pela constante LOGO_FADEOUT
Quando a última imagem desaparece, a cena termina.



Considerações


Este post foi um pouco diferente dos anteriores. Expusemos menos código aqui e nos concentramos mais nos aspectos de alto nível do motor. Houve uma boa refatoração no código entre as versões da parte 4 e da parte 5 e a maior parte da lógica de baixo nível com que vínhamos lidando foi escondida nas classes criadas a partir do diagrama exibido no início do texto.

Esta mudança foi proposital pois, se você acompanhou os outros textos e chegou até aqui, provavelmente as explicações linha a linha começam a ficar massantes e repetitivas.

Os efeitos de fadeIn e fadeOut podem parecer um pouco confusos se você não estiver familiarizado com o conceito de interpolação linear. Revisão o artigo anterior, na parte da animação de morte dos inimigos pode ajudar. Aqui há uma vídeo aula explicando a matemática da interpolação que utilizamos.

No próximo artigo, vamos aprimorar um pouco nosso motor e adicionar suporte a um sistema de partículas que nos permitirá adicionar efeitos visuais bem interessantes.

Espero que estejam gostando.

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

Links