Criando um Game Completo - Parte 7


Bem vindo à sétima 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.

Neste post iremos criar o menu inicial do game, definir a lógica de transição entre as telas e realizar os ajustes necessários na tela de gameplay para que o jogador possa voltar à tela inicial.




Menu Inicial


Nosso jogo precisa de uma tela inicial que permita que o jogador navegue por suas opções, inicie a jogatina e, no caso de um build para PC, encerre o software. Esta é a primeira tela com a qual o jogador se depara cada vez que o jogo é iniciado portanto, além de fornecer um mecanismo básico de navegação, temos a primeira oportunidade de definir o feeling do jogo.

Do que se trata este game? Qual a temática? Quem o produziu? Quando foi lançado? Todas estas perguntas podem ser respondidas se sua tela tiver um design adequado e, embora nosso foco aqui seja mais programação que game design, sabemos que nem sempre há um designer disponível para nos ajudar então, antes de partir para o código, vamos analisar um pouco os elementos desta tela:



Independentemente da natureza do game, estes elementos sempre estarão presentes em uma composição visual de qualidade:
  1. Logotipo
  2. Título
  3. Opções de navegação
    • mesmo que seja tão simples como um "pressione x para começar"
  4. Imagem de contexto sendo, geralmente, uma destas opções
    • personagem principal ou inimigo 
    • cena do game
  5. Plano de fundo animado
    • nuvens se movendo, estrelas brilhando, algum efeito de parallax, etc..
Se achar conveniente, uma mensagem curta e discreta de copyright e ano de lançamento também podem ser adicionadas, como o fizemos aqui. Além destes elementos, um pequeno jingle ou riff pode ser usado para dar um toque final ao seu menu. Assumindo que você esteja lendo em um navegador moderno, poderá ouvir o som que usaremos logo abaixo.





Depois de incluir estes elementos em seu layout, utilizando um bom editor de imagens como o photoshop, o próximo passo é exportar as imagens individualmente e recriar a composição via código. Se você utilizar fontes diferentes, será necessário criá-las no TFontManager e adicioná-las na pasta de assets para que o motor as entenda.

Opções de Navegação


O menu inicial é uma cena, portanto, uma unit chamanda scnMainMenu deverá ser criada no diretório de cenas do projeto e a classe TMainMenuScene precisa ser criada e registrada nas cenas do jogo. Para tanto, o método TSpaceInvaders.CreateScenes será modificado.

procedure TSpaceInvadersGame.CreateScenes;
var
  gamePlay  : TGamePlayScene;
  menu      : TMainMenuScene;
begin
  gamePlay := TGamePlayScene.Create(fPlayer);
  gamePlay.Name:= 'gamePlay';
  Scenes.Add(gamePlay);

  menu := TMainMenuScene.Create;
  menu.Name:= 'mainMenu';
  Scenes.Add(menu);

  {$IFDEF FPC}
  gamePlay.OnQuit  := @doOnSceneQuit;
  menu.OnQuit      := @doOnSceneQuit;
  {$ELSE}
  gamePlay.OnQuit  := doOnSceneQuit;
  menu.OnQuit      := doOnSceneQuit;
  {$ENDIF}
  Scenes.Current := menu; // define a cena inicial do game
end;

Agora a cena do menu agora está sendo exibida, mas, por enquanto ainda é só uma tela preta. Vamos começar a preenchê-la desenhando as opções do menu, mas antes para mantar as coisas simples, uma classe para encapsular o comportamento deste menu pode ser criada. Veja.

  TMenuOption = (moNewGame, moHighScore, moExit);

  { TMenu }

  TMenu = class
  private
    const
      YOFFSET = 30; //distância vertical entre as linhas
  public
    x, y: integer;  // ponto de origem do menu
    selected : TMenuOption;
    constructor Create;
    procedure Draw;
    procedure SelectNext(const amount: integer);
  end;

{ implementation }

constructor TMenu.Create;
begin
  x := 540;
  y := 450;
  selected := moNewGame;  //inicia com New Game selecionado
end;

procedure TMenu.Draw;
var
  engine : TEngine;

  // as opções não selecionadas ficam um pouco esmaecidas (alpha = 44)
  function getAlpha(item : TMenuOption) :UInt8;
  begin
    if self.selected = item then
       result := 255
    else
       result := 40;
  end;

begin
  engine := TEngine.GetInstance;
  engine.Text.Draw('new game',   x, y, engine.Fonts.MainMenu, getAlpha(moNewGame));
  engine.Text.Draw('high score', x, y + YOFFSET, engine.Fonts.MainMenu, getAlpha(moHighScore));
  engine.Text.Draw('exit',    x, y + 2 * YOFFSET, engine.Fonts.MainMenu, getAlpha(moExit));
end;

procedure TMenu.SelectNext(const amount: integer);
begin
  TEngine.GetInstance.Sounds.Play(sndMenu);
  selected:= TMenuOption(Ord(selected) + amount);
  if Ord(selected) < 0 then
     selected:= TMenuOption(0);
  if selected > High(TMenuOption) then
     selected := TMenuOption(High(TMenuOption));
end;

Agora vamos declarar fMenu como uma variável privada da cena e ajustar as opções de acordo com o input do usuário (seta para cima e seta para baixo mudam a opção e enter confirma a escolha, executando a ação equivalente). Para isto usaremos o método TMainMenuScene.doOnKeyUp.

procedure TMainMenuScene.doOnKeyUp(key: TSDL_KeyCode);
begin
  inherited doOnKeyUp(key);
  if fInputEnabled then
    case key of
      SDLK_UP   : SelectNext(-1);
      SDLK_DOWN : SelectNext(+1);
      SDLK_RETURN:
        begin
          case fMenu.selected of
            moNewGame:
              begin
                //evita que a opção seja selecionada mais de uma vez
                fInputEnabled := false;  

                //toca um som para confiar a escolha
                TEngine.GetInstance.Sounds.Play(sndNewGame); 

                //inicia o processo de fadout da cena
                fFader.FadeOut(0, FADE_OUT);

                //executa gotoNewGame em FADE_OUT milisegundos
                ExecuteDelayed(FADE_OUT, {$IFDEF FPC}@{$ENDIF}gotoNewGame);
              end;
            moHighScore:
              begin
                //not implemented yet
                TEngine.GetInstance.Sounds.Play(sndPlayerHit);
              end;
            moExit:
              begin
                doQuit(qtQuitGame, 0);
              end;
          end;
        end;
    end;
end;

Agora basta invocar fMenu.Draw em TMainMenuScene.doOnRender e o menu estará funcionando.


Outros Elementos


Para concluir a cena, precisamos desenhar os textos e imagens restantes além de tocar o riff e desenhar as estrelas ao fundo. Para nossa sorte, a maior parte do trabalho pesado já foi feito e podemos reaproveitar muita coisa. As estrelas, por exemplo: podemos utilizar a mesma classe que desenvolvemos na parte 6 desta série. O texture manager também está pronto, então basta carregar as imagens do disco e desenhá-las na posição correta. O mesmo vale para o gerenciador de som. Então tudo o que nos resta fazer é observar as coordenadas de cada objeto no photoshop e escrever o código equivalente.

procedure TMainMenuScene.doBeforeQuit;
begin
  inherited;
  TEngine.GetInstance.Sounds.StopMusic( fMenuMusic );
end;

procedure TMainMenuScene.doBeforeStart;
begin
  inherited;
  TEngine.GetInstance.Sounds.PlayMusic( fMenuMusic, 1 );
  fFader.FadeIn(0, FADE_IN);
  fState := stFadingIn;
end;

procedure TMainMenuScene.doFreeSounds;
begin
  TEngine.GetInstance.Sounds.FreeMusic(fMenuMusic);
end;

procedure TMainMenuScene.doLoadSounds;
begin
  fMenuMusic := TEngine.GetInstance.Sounds.LoadMusic(MENU_MUSIC);
end;

procedure TMainMenuScene.doLoadTextures;
var
  engine : TEngine;
begin
  engine := TEngine.GetInstance;
  engine.Textures.Clear;

  TEXTURE_LOGO    := engine.Textures.Load('aeonsoft-small.png');
  TEXTURE_PAWN    := engine.Textures.Load('paw-small.png');
  TEXTURE_GEAR    := engine.Textures.Load('gear-small.png');
  TEXTURE_MOON    := engine.Textures.Load('moon.png');
end;

constructor TMainMenuScene.Create;
begin
  inherited;
  fMenu := TMenu.Create;
  fAlpha:= 0;
  fStars := TStarField.Create(400);;
  fFader := TFader.Create;
  fInputEnabled := true;
end;

destructor TMainMenuScene.Destroy;
begin
  fMenu.Free;
  fStars.Free;
  fFader.Free;
  inherited Destroy;
end;

procedure TMainMenuScene.doOnRender(renderer: PSDL_Renderer);
const
  DIVIDER_Y = 388;
var
  src, dest : TSDL_Rect;
  engine: TEngine;
begin
  engine := TEngine.GetInstance;
  renderer := engine.Renderer;

  fStars.Draw;
  fMenu.Draw;

  src.x := 0;
  src.y := 0;


  //draw  moon
  src.w := engine.Textures[TEXTURE_MOON].W;
  src.h := engine.Textures[TEXTURE_MOON].h;

  dest.x := 500;
  dest.y := 76;
  dest.w := src.w;
  dest.h := src.h;
  SDL_SetTextureBlendMode(engine.Textures[TEXTURE_LOGO].Data, SDL_BLENDMODE_BLEND);
  SDL_SetTextureAlphaMod(engine.Textures[TEXTURE_MOON].Data, $FF);
  SDL_RenderCopy(renderer, engine.Textures[TEXTURE_MOON].Data, @src, @dest);



  //divider line
  SDL_SetRenderDrawColor(renderer, $FF, $FF, $FF, $FF);
  SDL_RenderDrawLine(renderer, 0, DIVIDER_Y, engine.Window.w, DIVIDER_Y);


  engine.Text.Draw('open', 280, 307, engine.Fonts.GUILarge, $FF);
  engine.Text.Draw('SPACE-INVADERS', 280, 347, engine.Fonts.GUILarge, $FF);
  engine.Text.Draw('Aeonsoft 2017 - An open source tribute to Taito''s classic', 280, 395, engine.Fonts.DebugNormal, 80);

  SDL_SetTextureBlendMode(engine.Textures[TEXTURE_LOGO].Data, SDL_BLENDMODE_BLEND);
  SDL_SetTextureAlphaMod(engine.Textures[TEXTURE_LOGO].Data, $FF);

  //gear
  src.w := engine.Textures[TEXTURE_GEAR].W;
  src.h := engine.Textures[TEXTURE_GEAR].H;
  dest.x := 164;
  dest.y := 294;
  dest.h := 102;
  dest.w := 90;
  SDL_RenderCopyEx(renderer, engine.Textures[TEXTURE_GEAR].Data,
      @src, @dest, fAngle, nil, SDL_FLIP_NONE);

  //pawn
  src.w := engine.Textures[TEXTURE_PAWN].W;
  src.h := engine.Textures[TEXTURE_PAWN].H;

  dest.x := dest.x+25;
  dest.y := dest.y+31;
  dest.h := 38;
  dest.w := 40;
  SDL_RenderCopy(renderer, engine.Textures[TEXTURE_PAWN].Data, @src, @dest);


  //logo
  src.w := engine.Textures[TEXTURE_LOGO].W;
  src.h := engine.Textures[TEXTURE_LOGO].H;

  dest.x := 225;
  dest.y := 368;
  dest.h := 40;
  dest.w := 40;
  SDL_RenderCopy(renderer, engine.Textures[TEXTURE_LOGO].Data, @src, @dest);

  case fState of
    stFadingIn, stFadingOut :
      begin
        expandToWindow(@dest);
        if fState = stFadingIn then
           SDL_SetRenderDrawColor(renderer, 0, 0, 0, $FF- fFader.Value)
        else
           SDL_SetRenderDrawColor(renderer, 0, 0, 0, fFader.Value);
        SDL_SetRenderDrawBlendMode(engine.Renderer, SDL_BLENDMODE_BLEND);
        SDL_RenderFillRect(renderer, @dest);
      end;
  end;

end;

procedure TMainMenuScene.doOnUpdate(const deltaTime: real);
begin
  inherited doOnUpdate(deltaTime);
  fAngle := fAngle + 25 * deltaTime;
  fStars.Update( deltaTime );
  fFader.Update( deltaTime );
end;

procedure TMainMenuScene.expandToWindow(r: PSDL_Rect);
begin
  r^.x :=0;
  r^.y :=0;
  r^.w := TEngine.GetInstance.Window.w;
  r^.h := TEngine.GetInstance.Window.h;
end;

procedure TMainMenuScene.gotoNewGame;
begin
  doQuit(qtQuitCurrentScene, 0);
  fInputEnabled := true;
end;

A unit scnMainMenu ficou bem simples e fácil de compreender. Nenhum objeto novo foi introduzido e ao criar uma nova cena com cerca de 300 linhas, começamos a perceber o benefício de ter criado a engine junto com o game.

Aplicando a mesmas idéias aos menus da tela de gameplay, chegamos á primeira versão jogável do game. Confira como está o visual do game até aqui:




Com isto terminamos o primeiro tópico da parte 7.

No próximo post, vamos configurar fazer os ajustes para compilar o game para Linux e Windows a partir do mesmo projeto e, a partir daí, começaremos a criar a tela e a infra para uma tabela de pontos online, armazenada em um banco de dados nas nuvens.

Até lá!


Links