Game rodando em debug no Lázarus |
No último artigo, o projeto começou a tomar uma cara de game. Criamos o código que distribui os inimigos na tela, incluímos o tratamento de colisões entre os tiros do jogador e os alienígenas e demos os primeiros passos na construção da interface do usuário.
Vamos seguir nossa jornada adicionando algumas animações extras, fazendo os inimigos se movimentar e atirar, ampliar a lógica de colisões para detectar os danos sofridos pelo jogador e adicionar vários efeitos sonoros.
No final, como bônus, vamos adicionar a capacidade de alternar entre os modos de tela cheia e exibição em janela. Acompanhe o código no GitHub ou faça o download nos links no final do texto e, mais uma vez, mãos à obra!
Movimentação dos Inimigos
Ciclo de movimentação dos inimigos |
Do ponto de partida, cada um irá se deslocar 3 colunas para a direita e em seguida descerá o equivalente a meia linha. Deste ponto o movimento é espelhado ou seja, o inimigo segue mais 3 colunas para a esquerda e, em seguida, mais meia linha para baixo, fechando um clico completo de movimentação. Ao final de um ciclo, cada inimigo estará exatamente 1 linha abaixo da posição em que estava originalmente. A figura ao lado mostra o caminho percorrido por um inimigo durante um ciclo de movimentação.
Este tipo de ciclo pode ser modelado com facilidade como uma máquina de estados finita.
Uma máquina de estados é um modelo usado para representar programas de computador como uma máquina abstrata que possui um número de estados finitos e bem definidos e só pode estar em um destes estados por vez.
No caso da movimentação dos inimigos, podemos dizer que há quatro estados possíveis, veja:
Estado | Descrição |
A | parado |
B | movendo-se para a direita |
C | movendo-se para baixo |
D | movendo-se para a esquerda |
Como definimos que o movimento horizontal, seja ele para esquerda ou direita é de exatamente 3 colunas e que o movimento descendente é de meia linha, tomando nosso grid de debug como base, chegamos às seguintes definições:
Identificador | Valor | Descrição |
OFFSET_X | 3 * DEBUG_CELL_SIZE |
deslocamento horizontal em pixels |
OFFSET_Y | DEBUG_CELL_SIZE div 2 |
deslocamento vertical em pixels |
Para ir de um estado a outro, um fato ou um evento deve ocorrer. No nosso caso, cada vez que o deslocamento total em um sentido atingir o limite imposto, a máquina responde mudando de estado. As trocas de estados nesta pequena máquina que estamos criando obedecem as seguintes regras:
# | Estado atual | Evento | Novo estado |
1 | A | jogo iniciado. | B |
2 | B | (deltaX >= OFFSET_X) | C |
3 | C | (deltaY >= OFFSET_Y) and (old_state = B) | D |
4 | C | (deltaY >= OFFSET_Y) and (old_state = D) | B |
5 | D | (deltaY >= OFFSET_Y) | C |
legenda: detltaX = deslocamento total em x, deltaY = deslocamento total em Y |
Gráfico da máquina de estados |
A tabela de estados em si, pode ser mapeada para um tipo enumerado e cada inimigo saberá duas informações sobre seu movimento: o estado atual e o estado anterior que iremos armazenar em duas variáveis privadas fOldMoveDirection e fMoveDirection.
O método update de TEnemy.Update será responsável por implementar as regras da máquina e fazer a mudança dos estados quando necessário
//Estados TEnemyMoveDirection = ( None, //A Right, //B Down, //C Left //D ); (...) procedure TEnemy.Update(const deltaTime : real); const OFFSET_X = 3 * DEBUG_CELL_SIZE; OFFSET_Y = DEBUG_CELL_SIZE div 2; var currTicks : UInt32; deltaX : real; limitX : real; deltaY : real; limitY : real; procedure CalcXParams( aDirection : TEnemyMoveDirection ); inline; begin deltaX := Abs(Position.X - fMovementOrigin.X); case aDirection of TEnemyMoveDirection.Right : limitX := fMovementOrigin.X + OFFSET_X; TEnemyMoveDirection.Left : limitX := fMovementOrigin.X - OFFSET_X; end; end; procedure CalcYParams; inline; begin deltaY := Abs(Position.Y - fMovementOrigin.Y); limitY := fMovementOrigin.Y + OFFSET_Y; end; procedure ChangeDirection( aDirection : TEnemyMoveDirection ); inline; begin fOldMoveDirection := fMoveDirection; fMoveDirection:= aDirection; end; begin if Assigned( Sprite ) then Sprite.Update(deltaTime); if ( fMoveDirection <> TEnemyMoveDirection.None ) then begin case fMoveDirection of TEnemyMoveDirection.Left : begin CalcXParams( TEnemyMoveDirection.Left ); Position.X -= fSpeed * deltaTime; if ( Position.X < limitX ) then Position.X := limitX; if ( deltaX = OFFSET_X ) then begin fMovementOrigin := Position; ChangeDirection( TEnemyMoveDirection.Down ); end; end; TEnemyMoveDirection.Right : begin CalcXParams( TEnemyMoveDirection.Right ); Position.X += fSpeed * deltaTime; if ( Position.X > limitX ) then Position.X := limitX; if ( deltaX = OFFSET_X ) then begin fMovementOrigin := Position; ChangeDirection( TEnemyMoveDirection.Down ); end; end; TEnemyMoveDirection.Down : begin CalcYParams; Position.Y += fSpeed * deltaTime; if ( Position.Y > limitY ) then Position.Y := limitY; if ( deltaY = OFFSET_Y ) then begin fMovementOrigin := Position; if ( fOldMoveDirection = TEnemyMoveDirection.Left ) then ChangeDirection( TEnemyMoveDirection.Right ) else ChangeDirection( TEnemyMoveDirection.Left ); end; end; end; end; end;Depois de realizar as alterações os inimigos estarão se movimentado de acordo com o valor de fMoveDirection, e as transições da máquina de estados estarão sendo computadas em TEnemy.Update.
Ótimo. Os inimigos estão se movendo, vamos, agora, fazê-los atirar.
Tiros e Colisões
Lambra-se de como fazemos o jogador atirar? Haviam algumas limitações que impusemos de propósito para que o game fluísse bem. Definimos um intervalo entre os tiros e delegamos a criação do tiro em si a quem implementasse o evento OnShot. Faremos a mesma coisa com os inimigos, mas com duas diferenças importantes.
- O jogador atira em resposta a um evento do teclado ou do gamepad. Os inimigos atirarão em responsa a um teste de probabilidade que será realizado a cada 2500ms.
- O jogador pode atirar a qualquer momento. Um inimigo só pode atirar se não houver outros inimigos em sua linha de tiro, caso contrário, eles matariam uns aos outros.
Implementar a primeira condição é fácil. Vamos criar uma variável privada em TEnemy para armazenar o tempo que o último teste de probabilidade foi realizado e, dentro do método update, utilizaremos este valor para saber quando podemos "jogar os dados" e verificar se o inimigo irá atirar.
Chamaremos esta variável de fLastShotIteration e a inicializaremos com 0 em TEnemy.InitFields.
Veja como ficou a versão final do método.
procedure TEnemy.Update(const deltaTime : real); const OFFSET_X = 3 * DEBUG_CELL_SIZE; OFFSET_Y = DEBUG_CELL_SIZE div 2; var currTicks : UInt32; deltaX : real; limitX : real; deltaY : real; limitY : real; procedure CalcXParams( aDirection : TEnemyMoveDirection ); inline; begin deltaX := Abs(Position.X - fMovementOrigin.X); case aDirection of TEnemyMoveDirection.Right : limitX := fMovementOrigin.X + OFFSET_X; TEnemyMoveDirection.Left : limitX := fMovementOrigin.X - OFFSET_X; end; end; procedure CalcYParams; inline; begin deltaY := Abs(Position.Y - fMovementOrigin.Y); limitY := fMovementOrigin.Y + OFFSET_Y; end; procedure ChangeDirection( aDirection : TEnemyMoveDirection ); inline; begin fOldMoveDirection := fMoveDirection; fMoveDirection:= aDirection; end; begin if Assigned( Sprite ) then Sprite.Update(deltaTime); if fCanShot and Alive then begin currTicks:= SDL_GetTicks; if (currTicks - fLastShotIteration >= SHOT_DELAY) then begin if Random(100) <= 10 then begin if Assigned(fOnShot) then fOnShot(Self); end; fLastShotIteration:= currTicks; end; end; if ( fMoveDirection <> TEnemyMoveDirection.None ) then begin case fMoveDirection of TEnemyMoveDirection.Left : begin CalcXParams( TEnemyMoveDirection.Left ); Position.X -= fSpeed * deltaTime; if ( Position.X < limitX ) then Position.X := limitX; if ( deltaX = OFFSET_X ) then begin fMovementOrigin := Position; ChangeDirection( TEnemyMoveDirection.Down ); end; end; TEnemyMoveDirection.Right : begin CalcXParams( TEnemyMoveDirection.Right ); Position.X += fSpeed * deltaTime; if ( Position.X > limitX ) then Position.X := limitX; if ( deltaX = OFFSET_X ) then begin fMovementOrigin := Position; ChangeDirection( TEnemyMoveDirection.Down ); end; end; TEnemyMoveDirection.Down : begin CalcYParams; Position.Y += fSpeed * deltaTime; if ( Position.Y > limitY ) then Position.Y := limitY; if ( deltaY = OFFSET_Y ) then begin fMovementOrigin := Position; if ( fOldMoveDirection = TEnemyMoveDirection.Left ) then ChangeDirection( TEnemyMoveDirection.Right ) else ChangeDirection( TEnemyMoveDirection.Left ); end; end; end; end; end;
Ótimo, mas como vimos acima, somente os inimigos que não possuem nenhum alienígena abaixo de si podem atirar para não correr o risco de criar fogo amigo. Como a classe TEnemy não sabe nada sobre outros inimigos, o melhor lugar para implementarmos esta lógica é em TEnemyList que mantém uma lista muito bem organizada de todos os inimigos do jogo.
procedure TEnemyList.Update(const deltaTime: real); var i: integer; enemy : TEnemy; linha: integer; begin inherited Update(deltaTime); //só pode atirar se não houver nenhum outro inimigo na linha de tiro for i:=0 to Pred(Self.Count) do begin enemy:= TEnemy(Self.Items[i]); linha := i div 20; enemy.CanShot := linha = 5; if linha < 5 then case linha of 0: enemy.CanShot := (not TEnemy(Self.Items[i+20]).Alive) and (not TEnemy(Self.Items[i+40]).Alive) and (not TEnemy(Self.Items[i+60]).Alive) and (not TEnemy(Self.Items[i+80]).Alive) and (not TEnemy(Self.Items[i+100]).Alive); 1: enemy.CanShot := (not TEnemy(Self.Items[i+20]).Alive) and (not TEnemy(Self.Items[i+40]).Alive) and (not TEnemy(Self.Items[i+60]).Alive) and (not TEnemy(Self.Items[i+80]).Alive); 2: enemy.CanShot := (not TEnemy(Self.Items[i+20]).Alive) and (not TEnemy(Self.Items[i+40]).Alive) and (not TEnemy(Self.Items[i+60]).Alive); 3: enemy.CanShot := (not TEnemy(Self.Items[i+20]).Alive) and (not TEnemy(Self.Items[i+40]).Alive); 4: enemy.CanShot := (not TEnemy(Self.Items[i+20]).Alive) ; end; end; end;Já que temos 20 inimigos por linha, dispostos em um padrão retangular, se somarmos 20 ao índice de um inimigo qualquer, podemos referenciar o inimigo exatamente abaixo dele. Dependendo da linha em que estamos no grid, devemos fazer mais ou menos comparações. As naves posicionadas na sexta linha sempre podem atirar, já que são a primeira linha de ataque dos alienígenas.
Para criar o projétil basta vamos o método TGame.doOnshot para criar uma instância de TShot e configurá-la de acordo com o emissor do evento, representado pelo parâmetro Sender.
procedure TGame.doOnShot(Sender: TGameObject); procedure CreateShot(Position: TPoint; Direction: TShotDirection); var shot : TShot; begin shot := TShot.Create( fRenderer ); shot.Sprite.Texture.Assign( fTextures[ Ord(TSpriteKind.ShotA) ] ); shot.Sprite.InitFrames( 1,1 ); shot.Position := Position; shot.Position.X -= (shot.Sprite.CurrentFrame.Rect.w / 2); shot.OnCollided := @doOnShotCollided; shot.DrawMode := GetDrawMode; shot.Direction:= Direction; fShots.Add( shot ); end; begin if (Sender is TPlayer) then CreateShot(TPlayer(Sender).ShotSpawnPoint, TShotDirection.Up) else if (Sender is TEnemy) then CreateShot(TEnemy(Sender).ShotSpawnPoint, TShotDirection.Down); end;
O mesmo vale para as checagens de colisão. Vamos estender TGame.CheckCollision para que ele também possa detectar colisões entre o jogador e os tiros disparados pelas naves.
procedure TGame.CheckCollision; var i : integer; shotList : TShotList; suspectList : TEnemyList; begin //check all shots going upwards with all alive enemies if (fShots.Count > 0) and ( fEnemies.Count > 0 ) then begin shotList := fShots.FilterByDirection( TShotDirection.Up ); suspectList := fEnemies.FilterByLife( true ); for i:=0 to Pred(shotList.Count) do TShot(shotList[i]).CheckCollisions( suspectList ); shotList.Free; suspectList.Free; end; //check all shots going downwards against the player if (fShots.Count > 0) then begin shotList := fShots.FilterByDirection( TShotDirection.Down ); for i:=0 to shotList.Count-1 do TShot(shotList[i]).CheckCollisions( fPlayer ); shotList.Free; end; end;
Animação de morte
Evolução da opacidade da explosão ao longo do tempo |
A idéia aqui também não é nova vamos criar um objeto TExplosion, descendente de TGameObject e uma classe para gerenciar todas as suas instâncias. Exatamente como fizemos com os inimigos e com os tiros. Veja a implementação desas duas classes no repositório.
Duas constantes governam este comportamento. LIFE_TIME define o tempo total em que a explosão será visível e START_FADE define em que ponto dentro de LIFE_TIME a explosão começará a esmaecer. A opacidade e a visibilidade do sprite são calculadas em TExplosion.Update. O gráfico acima mostra em detalhes a evolução de fOpacity em função do tempo.
procedure TExplosion.Update(const deltaTime: real); var elapsed: UInt32; opacity : extended; fadeTime: integer; begin if fVisible then begin elapsed := SDL_GetTicks - fCreatedTicks; if elapsed > START_FADE then begin elapsed -= START_FADE; fadeTime:= LIFE_TIME-START_FADE; opacity := 255 - ((elapsed / fadeTime) * 255 ); opacity:= Round(opacity); opacity:= Max(opacity, 0); opacity:= Min(255, opacity); fOpacity := Trunc(opacity); fVisible := elapsed < LIFE_TIME; end; end; end;
Agora no manipulador do evento OnShotCollided vamos criar, de fato, a explosão.
procedure TGame.doOnShotCollided(Sender, Suspect: TGameObject; var StopChecking: boolean); var shot : TShot; enemy : TEnemy; explostion : TExplosion; begin 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 ); if enemy.Alive then Inc(fScore, 10) else begin Inc(fScore, 100); explostion := TExplosion.Create(fRenderer); explostion.Sprite.Texture.Assign(fTextures[Ord(TSpriteKind.Explosion)]); explostion.Sprite.InitFrames(1,1); explostion.Position := enemy.Position; fExplosions.Add(explostion); end; fShots.Remove( shot ); StopChecking := true; exit; end; if ( Suspect is TPlayer ) then begin fPlayer.Hit( 1 ); explostion := TExplosion.Create(fRenderer); explostion.Sprite.Texture.Assign(fTextures[Ord(TSpriteKind.Explosion)]); explostion.Sprite.InitFrames(1,1); explostion.Position := TPlayer(Suspect).Position; fExplosions.Add(explostion); fShots.Remove( shot ); end; end; end;
Inclua uma chamada a fShots.Update em TGame.Update e você verá que ao matar um inimigo a animação é exibida, causando uma sensação visual bem melhor.
Efeitos sonoros
O game está bem mais interessante agora. Os inimigos se movimentam e atiram, o jogador sofre danos e até temos um efeito de explosão que é exibido durante a morte de uma nave inimiga. Mas falta som!
Os sons, são parte fundamental de qualquer experiência multi mídia. E os games não são exceção. Para tocar sons em nosso game, vamos recorrer a outro subsistema da SDL. O SDL Mixer. Mais uma vez, a integração desta extensão do SDL é bastante tranquila. Baixe os binários adequados à sua plataforma, extraia as dlls no diretório .\bin do projeto, inclua a unit SDL2_mixer na uses de sdlGame.pas e estamos prontos para iniciar o subsistema de som.
Assim como fizemos ao adicionar suporte ao joystick, precisamos chamar a função SDL_Init passando um flag indicando que queremos iniciar o sistema de áudio. O flag em questão se chama SDL_INIT_AUDIO e pode ser combinado os outros flags que já usamos. Depois de informado à SDL que pretendemos utilizar o subsistema de áudio, é necessário carregar as bibilotecas. Faremos isto com uma chamada à função Mix_OpenAudio. Veja como ficou a nova versão de nossa rotina de inicialização e de finalização.
procedure TGame.Initialize; 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); fWindow := SDL_CreateWindow( PAnsiChar( WINDOW_TITLE ), SDL_WINDOWPOS_UNDEFINED, SDL_WINDOWPOS_UNDEFINED, SCREEN_WIDTH, SCREEN_HEIGHT, SDL_WINDOW_SHOWN); if fWindow = nil then raise SDLException.Create(SDL_GetError); fWindowSurface := SDL_GetWindowSurface( fWindow ); if fWindowSurface = nil then raise SDLException.Create(SDL_GetError); fRenderer := SDL_CreateRenderer(fWindow, -1, SDL_RENDERER_ACCELERATED {or SDL_RENDERER_PRESENTVSYNC}); 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, MIX_DEFAULT_FORMAT, 2, 2048); if result < 0 then raise SDLMixerException.Create( Mix_GetError ); Randomize; LoadTextures; LoadSounds; CreateFonts; CreateGameObjects; fGameText := TGameTextManager.Create( fRenderer ); StartNewGame; end; procedure TGame.Quit; begin FreeGameObjects; FreeTextures; FreeFonts; FreeSounds; fGameText.Free; if fJoystick <> nil then begin SDL_JoystickClose(fJoystick); fJoystick:= nil; end; fFrameCounter.Free; SDL_DestroyRenderer(fRenderer); SDL_DestroyWindow(fWindow); IMG_Quit; Mix_Quit; SDL_Quit; end;
Passamos SDL_INIT_AUDIO para SDL_Init e, mais abaixo, abrimos efetivamente o sistema de som através da função Mix_OpenAudio. Note que os valores que passamos aqui são todos valores padrão para reprodução de áudio e deve, portanto, ser válido para a maioria das placas de som disponíveis no mercado - incluindo as placas onboard que acompanham praticamente todos os PCs.
O primeiro parâmetro é a frequência em que os sons serão reproduzidos. O número 44100 é bem conhecido para quem trabalha com música. Esta é a frequência padrão da indústria para reproduzir áudio com qualidade de CD. Depois passamos MIX_DEFAULT_FORMAT como valor para o parâmetro de formato dos samples. Este valor, na implementação, representa samples de aúdio 16bits, um outr valor padrão no mundo da música. Em seguida temos a quantidade de canais (2 para sons estéreo) e, por fim, o tamanho do buffer de memória para cada som. Este é o único parâmetro para o qual não realmente um padrão, mas a regra, como em todos os algoritmos basedos em buffers é, quanto maior o buffer, melhor. Fique à vontade para experimentar valores diferentes.
Na listagem acima, aparecem duas funções novas. LoadSounds e FreeSounds. Elas carregam e liberam, respectivamente, nossos efeitos sonoros para um array privado em TGame. Como não queremos ficar lembrando dos índices de cada som ao longo do código, também criamos um tipo enumerado para representar cada um deles.
//enumerado para nos ajudar a lembrar o índice dos sons dentro de nosso //array de chunks TSoundKind = ( sndEnemyBullet, sndEnemyHit, sndPlayerBullet, sndPlayerHit, sndGamePause, sndGameResume, sndGameOver ); TGame = class strict private const WINDOW_TITLE = 'Delphi Games - Space Invaders'; var fRunning : boolean; fWindow : PSDL_Window; fWindowSurface : PSDL_Surface; fRenderer : PSDL_Renderer; fFrameCounter : TFPSCounter; fTextures : array of TTexture; fSounds : array of PMix_Chunk; //PMix_Chunk armazera os bytes de um arquivo de som (....) procedure TGame.LoadSounds; const SOUND_DIR = '.\assets\sounds\'; begin SetLength(fSounds, Ord(High( TSoundKind))+1); fSounds[ Ord(TSoundKind.sndEnemyBullet) ] := Mix_LoadWAV(SOUND_DIR + 'EnemyBullet.wav'); fSounds[ Ord(TSoundKind.sndEnemyHit) ] := Mix_LoadWAV(SOUND_DIR + 'EnemyHit.wav'); fSounds[ Ord(TSoundKind.sndPlayerBullet) ] := Mix_LoadWAV(SOUND_DIR + 'PlayerBullet.wav'); fSounds[ Ord(TSoundKind.sndPlayerHit) ] := Mix_LoadWAV(SOUND_DIR + 'PlayerHit.wav'); fSounds[ Ord(TSoundKind.sndGamePause) ] := Mix_LoadWAV(SOUND_DIR + 'GamePause.wav'); fSounds[ Ord(TSoundKind.sndGameResume) ] := Mix_LoadWAV(SOUND_DIR + 'GameResume.wav'); fSounds[ Ord(TSoundKind.sndGameOver) ] := Mix_LoadWAV(SOUND_DIR + 'GameOver.wav'); end; procedure TGame.FreeSounds; var i : integer; begin for i:=Low(fSounds) to High(fSounds) do Mix_FreeChunk(fSounds[i]); end;
Com os sons devidamente carregados na memória, podemos tocá-los com uma chama da Mix_PlayChannel, que aceita 3 parâmetros: o canal aonde o som será executado, o endereço dos samples do som e o número de vezes que o som será repetido (caso queiramos um loop). Tudo que precisamos fazer agora é localizar os momentos certos para tocar um ou outro efeito sonoro.
Quando um tiro for disparado, por exemplo:
procedure TGame.doOnShot(Sender: TGameObject); procedure CreateShot(Position: TPoint; Direction: TShotDirection); var shot : TShot; begin shot := TShot.Create( fRenderer ); shot.Sprite.Texture.Assign( fTextures[ Ord(TSpriteKind.ShotA) ] ); shot.Sprite.InitFrames( 1,1 ); shot.Position := Position; shot.Position.X -= (shot.Sprite.CurrentFrame.Rect.w / 2); shot.OnCollided := @doOnShotCollided; shot.DrawMode := GetDrawMode; shot.Direction:= Direction; fShots.Add( shot ); end; begin if (Sender is TPlayer) then begin CreateShot(TPlayer(Sender).ShotSpawnPoint, TShotDirection.Up); Mix_Volume(1, 30); Mix_PlayChannel(1, fSounds[ Ord(TSoundKind.sndPlayerBullet) ], 0); end else if (Sender is TEnemy) then begin CreateShot(TEnemy(Sender).ShotSpawnPoint, TShotDirection.Down); Mix_PlayChannel(1, fSounds[ Ord(TSoundKind.sndEnemyBullet) ], 0); end; end;Ou quando um tiro colidir com o jogador ou o inimigo
procedure TGame.doOnShotCollided(Sender, Suspect: TGameObject; var StopChecking: boolean); var shot : TShot; enemy : TEnemy; procedure CreateExplosion(Position: TPoint); var explostion : TExplosion; begin explostion := TExplosion.Create(fRenderer); explostion.Sprite.Texture.Assign(fTextures[Ord(TSpriteKind.Explosion)]); explostion.Sprite.InitFrames(1,1); explostion.Position := Position; fExplosions.Add(explostion); end; begin 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 ); Mix_PlayChannel(-1, fSounds[ Ord(TSoundKind.sndEnemyHit) ], 0); if enemy.Alive then Inc(fScore, 10) else begin Inc(fScore, 100); CreateExplosion(enemy.Position); end; fShots.Remove( shot ); StopChecking := true; exit; end; if ( Suspect is TPlayer ) then begin fPlayer.Hit( 1 ); CreateExplosion(TPlayer(Suspect).Position); Mix_PlayChannel(-1, fSounds[ Ord(TSoundKind.sndEnemyHit) ], 0); fShots.Remove( shot ); end; end; end;
Simples não? Mas faz uma diferença e tanto!
Com a soma destas pequenas mudanças, o game já está bem mais interessante. Para finalizar este artigo, vamos adicionar duas novas características. Uma tela de Game Over e a capacidade de pausar/retomar o game.
Estados do Game
Game exibindo a tela de pause/resume |
Vamos introduzir uma máquina de estados no nível de TGame para implementar ostes estados essenciais e alterar alguns métodos para serem executados ou não de acordo ela.
O método mais fácil de implementar é pause. Quando o game estiver pausado, o estado dos objetos não é atualizado ou seja, nenhum TGameObject.Update deve ser chamado, mas as rotinas de renderização continuam com seu fluxo normal. Vamos utilizar tanto a tecla
Declare uma variável privada do tipo TGameState em TGame e inicialize-a com TGameState.Playing. Em seguida, vamos alterar o estado do jogo em resposta aos comando de pausa/retorno alterando a função TGame.HandleEvents. Note que também vamos tocar um som em resposta a esta mudança de status.
TGameState = ( Playing, Paused, GameOver ); (...) procedure TGame.HandleEvents; var event : TSDL_Event; begin while SDL_PollEvent( @event ) = 1 do begin case event.type_ of SDL_QUITEV : fRunning := false; SDL_KEYDOWN : case event.key.keysym.sym of //player controls SDLK_LEFT, SDLK_A : fPlayer.Input[Ord(TPlayerInput.Left)] := true; SDLK_RIGHT, SDLK_D : fPlayer.Input[Ord(TPlayerInput.Right)]:= true; SDLK_SPACE : fPlayer.Input[Ord(TPlayerInput.Shot)] := true; SDLK_p : ScreenShot; SDLK_g : SetDebugView( not fDebugView ); SDLK_ESCAPE : fRunning := false; end; SDL_KEYUP : case event.key.keysym.sym of //player controls SDLK_LEFT, SDLK_A : fPlayer.Input[Ord(TPlayerInput.Left)] := false; SDLK_RIGHT, SDLK_D : fPlayer.Input[Ord(TPlayerInput.Right)]:= false; SDLK_SPACE : fPlayer.Input[Ord(TPlayerInput.Shot)] := false; SDLK_f: ToggleFullScreen; SDLK_o: begin fGameState := TGameState.GameOver; Mix_PlayChannel(0, fSounds[ Ord(TSoundKind.sndGameOver) ], 0); end; SDLK_r: StartNewGame; //reset the game SDLK_RETURN : begin case fGameState of TGameState.Paused : begin fGameState:= TGameState.Playing; Mix_PlayChannel(-1, fSounds[ Ord(TSoundKind.sndGameResume) ], 0); end; TGameState.Playing : begin fGameState:= TGameState.Paused; Mix_PlayChannel(-1, fSounds[ Ord(TSoundKind.sndGamePause) ], 0); end; TGameState.GameOver: StartNewGame; end; end; end; SDL_JOYAXISMOTION : case event.jaxis.axis of //X axis motion 0 : begin fPlayer.Input[Ord(TPlayerInput.Left)] := false; fPlayer.Input[Ord(TPlayerInput.Right)] := false; if event.jaxis.value > 0 then fPlayer.Input[Ord(TPlayerInput.Right)] := true else if event.jaxis.value < 0 then fPlayer.Input[Ord(TPlayerInput.Left)] := true end; end; SDL_JOYBUTTONUP : case event.jbutton.button of 0, 1, 2, 3 : fPlayer.Input[Ord(TPlayerInput.Shot)] := false; 9: // 9 for stard button //http://wiki.gp2x.org/articles/s/d/l/SDL_Joystick_mapping.html begin case fGameState of TGameState.Paused : begin fGameState:= TGameState.Playing; Mix_PlayChannel(-1, fSounds[ Ord(TSoundKind.sndGameResume) ], 0); end; TGameState.Playing : begin fGameState:= TGameState.Paused; Mix_PlayChannel(-1, fSounds[ Ord(TSoundKind.sndGamePause) ], 0); end; TGameState.GameOver: StartNewGame; end; end; end; SDL_JOYBUTTONDOWN : case event.jbutton.button of 0, 1, 2, 3 : fPlayer.Input[Ord(TPlayerInput.Shot)] := true; end; end; end; end;Mudamos o estado do game, mas para que o jogo respeite esta mudança, é necessário atualizar os objetos somente quando estivermos jogando. Vamos alterar TGame.Update para refletir isto.
procedure TGame.Update(const deltaTime : real ) ; begin case fGameState of TGameState.Playing : begin fPlayer.Update( deltaTime ); fEnemies.Update( deltaTime ); fShots.Update( deltaTime ); fExplosions.Update( deltaTime ); if ( fPlayer.Lifes <=0) then begin fGameState := TGameState.GameOver; Mix_PlayChannel(-1, fSounds[ Ord(TSoundKind.sndGameOver) ], 0); end; end; TGameState.Paused : begin end; TGameState.GameOver: begin end; end; end;
Agora o jogo realmente pára em resposta ao pressionamento da tecla
Para ficar mais bacana, vamos esmaecer o cenário e exibir um texto por cima informado ao jogador o estado que o jogo se encontra. Vamos aproveitar e também criar a tela de game over neste passo. O método que precisamos alterar é TGame.DrawGui.
procedure TGame.DrawGUI; var rect : TSDL_Rect; begin SDL_SetRenderDrawColor(fRenderer, 255, 255, 0, 255); SDL_RenderDrawLine( fRenderer, 0, round(DEBUG_CELL_SIZE * 1.5), SCREEN_WIDTH, round(DEBUG_CELL_SIZE * 1.5)); rect.x:= 0; rect.y:= 0; rect.h:= round(DEBUG_CELL_SIZE * 1.5); rect.w:= SCREEN_WIDTH; SDL_SetRenderDrawColor(fRenderer, 255, 0, 0, 80); SDL_RenderFillRect( fRenderer, @rect ); fGameText.Draw( Format('SCORE %.6d', [fScore]), 290, 12, fGameFonts.GUI ); rect.x:= 710; rect.y:= 18; rect.h:= 2 *fPlayer.Sprite.Texture.H div 3; rect.w:= 2 *fPlayer.Sprite.Texture.W div 3; SDL_RenderCopy(fRenderer, fPlayer.Sprite.Texture.Data, @fPlayer.Sprite.CurrentFrame.Rect, @rect); fGameText.Draw( Format('%.2d', [fPlayer.Lifes]), 738, 12, fGameFonts.GUI ); case fGameState of TGameState.Paused : begin //obsfuscates the game stage rect.x := 0; rect.y := round( 1.5 * DEBUG_CELL_SIZE) +1; rect.h := SCREEN_HEIGHT - rect.y; rect.w:= SCREEN_WIDTH; SDL_SetRenderDrawColor(fRenderer, 0, 0, 0, 200); SDL_RenderFillRect( fRenderer, @rect ); fGameText.Draw( '***[ PAUSED ]***' , 155, SCREEN_HALF_HEIGHT-24, fGameFonts.GUI64 ); if SDL_NumJoysticks = 0 then fGameText.Draw( 'pressto resume', 320, SCREEN_HALF_HEIGHT+25, fGameFonts.DebugNormal ) else fGameText.Draw( 'press to resume', 320, SCREEN_HALF_HEIGHT+25, fGameFonts.DebugNormal ); end; TGameState.GameOver : begin //obsfuscates the game stage rect.x := 0; rect.y := round( 1.5 * DEBUG_CELL_SIZE) +1; rect.h := SCREEN_HEIGHT - rect.y; rect.w:= SCREEN_WIDTH; SDL_SetRenderDrawColor(fRenderer, 50, 0, 0, 200); SDL_RenderFillRect( fRenderer, @rect ); fGameText.DrawModulated( '***[ GAME OVER ]***' , 105, SCREEN_HALF_HEIGHT-24, fGameFonts.GUI64, 255,0,0 ); if SDL_NumJoysticks = 0 then fGameText.Draw( 'press to start a new game', 285, SCREEN_HALF_HEIGHT+25, fGameFonts.DebugNormal ) else fGameText.Draw( 'press to start a new game', 285, SCREEN_HALF_HEIGHT+25, fGameFonts.DebugNormal ); end; end; end;
Com isto concluímos as modificações planejadas para este post.
Implementamos bastante coisa até aqui e neste ponto temos um game funcional. As características básicas que se repetem em todos os games de gráficos 2d estão aí. Já sabemos como exibir sprites animados, como modelar comportamentos utilizando máquinas de estados, como exibir textos baseados em fontes True Type e como tocar sons.
Pode não parecer, mas as idéias e códigos mais avançados sobre os quais iremos nos debruçar nos próximos textos são, em sua maioria, especializações do que vimos até aqui, por isso é importante que você entenda bem o conteúdos destes quatro primeiros textos.
Para finalizar, o vídeo abaixo mostra o projeto que construímos. É possível ver a tela de pause/resume e game over, as colisões e a movimentação dos inimigos. No final, é exibido um gameplay na visão de debug, onde os limites de movimentação, os retângulos de colisão e a grade na qual a posição de todos os objetos do jogo são baseados.
Abraços, bons estudos e até a próxima.