Sequencia de introdução do game |
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 |
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:
- TFonts, para encapsular as fontes do game
- TTextManager, para desenhar textos na tela usando as fontes de TFonts
- TSoundManager, para organizar os sons em memória e realizar seu playback
- TTexrureManager, para organizar as instâncias de TTexture
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:
- 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. - VISIBE
A imagem está 100% visível.
O alpha permanece em 255 durante LOGO_VISIBLE milissegundos. - 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.