No último artigo, preparamos as fundações e demos os primeiros passos na construção do game. Agora, com as bases prontas, vamos começar a nos concentrar no conteúdo - imagens, animações e interação entre os objetos do jogo e a interface do usuário.
Este post será maior que os outros dois, construiremos objetos mais complexos e resolveremos alguns problemas mais difíceis portanto, é aconselhável baixar o código-fonte e ir acompanhando as explicações utilizando-o como referência. Caso não queira fazer o download, você também pode ver o código no repositório do GitHub.
Abraços, e mão à obra!
Layout
Grade de layout do game. |
Observe a imagem ao lado. Ela exibe a grade na qual iremos nos basear para criar o layout do game daqui para frente. É muito importante ter um modelo de referência visual quando estamos programando gráficos porquê eles ajudam a julgar a exatidão do posicionamento das coisas com uma simples olhada na tela ao invés de ficar projetando mentalmente as coordenadas dos objetos e comparar o resultado com o que se vê no monitor.
Dividimos o espaço da seguinte maneira:
- Um grupo de células que chamaremos de seção superior (onde os inimigos estão)
- Um grupo de células que chamaremos de seção inferior (onde o jogador está)
- Células externas que nos servirão de margem. Nem o jogador, nem os inimigos poderão mover-se para além delas.
De posse destas definições, montamos a tabela de constantes abaixo:
Nome | Valor | Observação |
SCREEN_WIDTH | 800 | Largura da tela |
SCREEN_HEIGHT | 600 | Altura da tela |
SCREEN_HALF_WIDTH | SCREEN_WIDTH/2 | Centro da tela no eixo X |
SCREEN_HALF_HEIGHT | SCREEN_HEIGHT/2 | Centro da tela no eixo Y |
DEBUG_CELL_SIZE | 32 | Tamanho das células do grid |
DEBUG_CELL_COUNT_V | SCREEN_WIDTH / DEBUG_CELL_SIZE | Número de células em 1 linha |
DEBUG_CELL_COUNT_H | SCREEN_HEIGHT / DEBUG_CELL_SIZE | Número de células em 1 coluna |
unit sdlGameTypes; {$mode objfpc}{$H+} interface uses sysutils; const SCREEN_WIDTH = 800; SCREEN_HEIGHT = 600; SCREEN_HALF_WIDTH = SCREEN_WIDTH div 2; SCREEN_HALF_HEIGHT = SCREEN_HEIGHT div 2; DEBUG_CELL_SIZE = 32; DEBUG_CELL_COUNT_V = (SCREEN_WIDTH div DEBUG_CELL_SIZE); DEBUG_CELL_COUNT_H = (SCREEN_HEIGHT div DEBUG_CELL_SIZE); type SDLException = class( Exception ); SDLImageException = class( SDLException ); SDLTTFException = class( SDLException ); IndexOutOfBoundsException = class( Exception ); implementation end.
Não esqueça de adicionar sdlGameTypes na cláusula uses da unit sdlGame, para não quebrar a compilação.
Grid de Debug
Precisamos de um gatilho para desenhar ou ocultar o grid explicado na seção anterior. Para isto, um flag de debug em TGame é suficiente. Assim, dependendo do estado do flag, chamamos ou não as rotinas responsável por seu desenho. Para ativar/desativar o modo debug, vamos utilizar a tecle G, de "Grid", já que a tecla D, mais adequada para "Debug" já é utilizada para controlar o jogador.
Declare fDebugView como campo privado de TGame e vamos alterar TGame.HandleEvents para tratar o pressionamento da tecla G.
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 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 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; end; SDL_JOYAXISMOTION : case event.jaxis.axis of 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; end; SDL_JOYBUTTONDOWN : case event.jbutton.button of 0, 1, 2, 3 : fPlayer.Input[Ord(TPlayerInput.Shot)] := true; end; end; end; end; (...) procedure TGame.SetDebugView(const aValue: boolean); begin fDebugView := aValue; fShots.SetDrawMode(GetDrawMode); fEnemies.SetDrawMode(GetDrawMode); end; (...) procedure TGame.ScreenShot; const SCREENSHOT_DIR = '.\screenshots\'; SCREENSHOT_PREFIX = 'shot'; function GetNewFileName: string; var i: integer; begin i:= 0; result := Format('%s-%.3d.bmp', [SCREENSHOT_DIR + SCREENSHOT_PREFIX, i]); while FileExists(result) do begin result := Format('%s-%.3d.bmp', [SCREENSHOT_DIR + SCREENSHOT_PREFIX, i]); Inc(i); end; end; var surface : PSDL_Surface; begin surface:= nil; try surface:= SDL_CreateRGBSurface(0, SCREEN_WIDTH, SCREEN_HEIGHT, 32, $00FF0000, $0000FF00, $000000FF, $FF000000); SDL_RenderReadPixels( fRenderer, nil, SDL_PIXELFORMAT_ARGB8888, surface^.pixels, surface^.pitch ); ForceDirectories(SCREENSHOT_DIR); SDL_SaveBMP(surface, GetNewFileName); finally SDL_FreeSurface( surface ); end; end; (...) function TGame.GetDrawMode: TDrawMode; begin if (fDebugView) then result := TDrawMode.Debug else result := TDrawMode.Normal; end;
Há bastante coisa acontecendo aqui. Vamos explicá-las na ordem em que aparecem no código.
A primeira coisa nova, é a captura da tecla P no SDL_KEYUP para chamar a função ScreenShot. Esta é uma rotina serve para capturar "fotos" da tela e salvá-las em disco. Não é necessário para o game em si, mas é um recurso bacana e pode ajudar a simplificar o debug. O que ela faz é criar um PSDL_Surface temporário, copiar os pixels da tela através de SDL_RenderReadPixels e depois salvá-los em disco no formato bitmap usando SDL_SaveBMP.
Logo após, podemos ver que quando capturamos a tecla G, fazemos uma chamada a SetDebugView que vai inverter o valor do flag fDebugView e configurar os objetos do jogo para o modo de desenho equivalente, o que nos leva à próxima modificação.
A função GetDrawMode devolve, como retorno, uma variável do tipo TDrawMode. De onde veio isto?
Assim como TGame agora "sabe" se está em modo de debug ou não, os nossos objetos também deveriam saber e adaptar seu desenho de acordo. Veja:
TSpriteAnimationType = ( NoLoop, Circular ); (...) { TGameObject } PGameObject = ^TGameObject; TGameObject = class private function GetSpriteRect: TSDL_Rect; strict protected fRenderer : PSDL_Renderer; fDrawMode : TDrawMode; protected fSprite : TSprite; procedure InitFields; virtual; public Position : TPoint; constructor Create( const aRenderer: PSDL_Renderer ); virtual; destructor Destroy; override; procedure Update(const deltaTime : real ); virtual; abstract; procedure Draw; virtual; abstract; procedure CheckCollisions( Suspects: TGameObjectList); property DrawMode: TDrawMode read fDrawMode write fDrawMode; property Sprite : TSprite read fSprite; property SpriteRect : TSDL_Rect read GetSpriteRect; end; (...) procedure TGameObject.InitFields; begin Position.X := 0; Position.Y := 0; fSprite := TSprite.Create; fDrawMode := TDrawMode.Normal; end;
Declaramos o modo de desenho como um tipo enumerado, adicionamos uma propriedade equivalente a TGameObject e sempre a inicializamos como TDrawMode.Normal.
Por fim, podemos nos concentrar no desenho do grid:
procedure TGame.DrawDebugGrid; procedure HighlightSecions; var lPlayerBoundary : TSDL_Rect; //seção inferior lEnemyBoundary : TSDL_Rect; //seção superior begin lPlayerBoundary.x := DEBUG_CELL_SIZE; lPlayerBoundary.y := DEBUG_CELL_SIZE * 17; lPlayerBoundary.w := SCREEN_WIDTH - (2 * DEBUG_CELL_SIZE); lPlayerBoundary.h := DEBUG_CELL_SIZE; //1 célula de altura lEnemyBoundary.x := DEBUG_CELL_SIZE; lEnemyBoundary.y := DEBUG_CELL_SIZE * 2; lEnemyBoundary.w := SCREEN_WIDTH - (2 * DEBUG_CELL_SIZE); lEnemyBoundary.h := DEBUG_CELL_SIZE * 14; SDL_SetRenderDrawBlendMode(fRenderer, SDL_BLENDMODE_BLEND); SDL_SetRenderDrawColor(fRenderer, 255, 0, 0, 50); SDL_RenderFillRect( fRenderer, @lPlayerBoundary ); SDL_RenderFillRect( fRenderer, @lEnemyBoundary ); end; var i, x, y : integer; begin if fDebugView then begin HighlightSecions; SDL_SetRenderDrawBlendMode(fRenderer, SDL_BLENDMODE_BLEND); SDL_SetRenderDrawColor(fRenderer, 255, 0, 0, 130); //draw horizontal lines for i:=0 to DEBUG_CELL_COUNT_H do begin y := i*DEBUG_CELL_SIZE; SDL_RenderDrawLine(fRenderer, 0, y, SCREEN_WIDTH, y); end; //draw vertical lines for i:=0 to DEBUG_CELL_COUNT_V-1 do begin x := i* DEBUG_CELL_SIZE; SDL_RenderDrawLine(fRenderer, x, 0, x, SCREEN_HEIGHT); end; //draw center lines in green SDL_SetRenderDrawColor(fRenderer, 0, 200, 0, 130); SDL_RenderDrawLine( fRenderer, SCREEN_HALF_WIDTH, 0, SCREEN_HALF_WIDTH, SCREEN_HEIGHT); SDL_RenderDrawLine( fRenderer, 0, SCREEN_HALF_HEIGHT, SCREEN_WIDTH, SCREEN_HALF_HEIGHT); end; end;Começamos iluminando as seções do grid com um vermelho semi-transparente dentro de HighlightSecions e calculamos suas dimensões utilizando os valores da tabela de constantes que criamos previamente. Seguimos com o desenho das linhas horizontais e verticais e finalizamos com duas linhas verdes que passam pelo ponto central da tela, dividindo-a em 4 quadrantes.
Arrays Dinâmicos x Listas Genéricas
A linguagem Pascal oferece o recurso de arrays dinâmicos, que fornecem a possibilidade de aumentar ou diminuir o tamanho de um array em tempo de execução. Um recurso realmente muito útil que vínhamos utilizando até então. Mas há um problema. Esta facilidade de uso nos levou a implementar a lógica de inicialização e liberação dos arrays dentro do código de TGame, misturando conceitos e dificultando a expansão do código.
Veja bem, o que queremos de verdade é um objeto capaz de lidar com listas ou coleções de outros objetos, nos escondendo os detalhes de sua implementação. Se por um lado, é fácil redimensionar um array dinâmico, por outro lado não há como adicionar funcionalidades novas a seu comportamento.
O free pascal suporta o conceito de classes genéricas (também chamados de metaprogramação ou de programação de templates em outras linguagens) e, por consequência, também suporta a criação de listas genéricas (veja a documentação para mais detalhes), o que nos fornece um substituto muito apto para nossos arrays. A idéia é encapsular o comportamento de lista em uma classe sem nos preocupar com os algoritmos necessários para mantê-la.
TGGameObjectList = specialize TFPGObjectList< TGameobject >; { TGameObjectList } TGameObjectList = class (TGGameObjectList) public procedure Update(const deltaTime : real ); virtual; procedure Draw; virtual; procedure SetDrawMode( aDrawMode : TDrawMode); end; (...) procedure TGameObjectList.Update(const deltaTime: real); var i: integer; begin for i:=0 to Pred(Self.Count) do Self.Items[i].Update( deltaTime ); end; procedure TGameObjectList.Draw; var i: integer; begin for i:=0 to Pred(Self.Count) do Self.Items[i].Draw; end; procedure TGameObjectList.SetDrawMode(aDrawMode: TDrawMode); var i: integer; begin for i:=0 to Pred(Self.Count) do Self.Items[i].DrawMode := aDrawMode; end;
Primeiro, declaramos TGGameObjectList como uma espcialização do tipo parametrizado, ou genérico, TFPObjectList
Com isto, chegamos a um contêiner mais capaz para nossos objetos. Se quisermos ser ainda mais específicos, e mais claros, podemos declarar listas específicas para TEnemy e para TShot. Declare as classes abaixo, altere todas as referências de fEnemies e fShots para o novo tipo equivalente e seu código voltará a compilar.
TEnemyList = class(TGameObjectList); TShotList = class(TGameObjectList); (...) procedure TGame.Update(const deltaTime : real ) ; begin fPlayer.Update( deltaTime ); fEnemies.Update( deltaTime ); fShots.Update( deltaTime ); end; procedure TGame.DrawGameObjects; begin fPlayer.Draw; fEnemies.Draw; fShots.Draw; end; procedure TGame.FreeGameObjects; begin fEnemies.Free; fShots.Free; end; constructor TGame.Create; begin fRunning := false; fJoystick := nil; fDebugView := false; fEnemies := TEnemyList.Create; end; procedure TGame.CreateGameObjects; var i : integer; enemy : TEnemy; begin for i:= 0 to 119 do fEnemies.Add( enemy ); fPlayer := TPlayer.Create( fRenderer ); fPlayer.Sprite.Texture.Assign( fTextures[ integer(TSpriteKind.Player)] ); fPlayer.Sprite.InitFrames(1,1); fPlayer.OnShotTriggered:= @doPlayer_OnShot; //centraliza o jogador no eixo X fPlayer.Position.X := trunc( SCREEN_HALF_WIDTH - ( fPlayer.Sprite.Texture.W / 2 )); //posiciona o jogador na 19º linha do grid fPlayer.Position.Y := (DEBUG_CELL_SIZE * 18) - fPlayer.Sprite.CurrentFrame.Rect.h; fShots := TShotList.Create(true); end;
Distribuição dos inimigos
Distribuição final do inimigos na tela. Note como eles estão perfeitamente alinhados à grade. |
Agora que temos um grid para nos guiar, vamos distribuir os inimigos num padrão retangular como o da figura acima: 6 linhas contendo 20 inimigos cada, sendo 2 linhas para o inimigo do tipo A, 2 para o tipo B e 2 para o Tipo C nesta ordem com o inimigo tipo C ocupando as primeiras linhas e o tipo A ocupando as linhas de baixo.
Temos 120 inimigos no total ( 6*20 =120 ). Se você já terminou o curso de álgebra do ensino médio deve ser capaz de deduzir as fórmulas para calcular as coordenadas de cada um dos 120 inimigos, se não, não se preocupe, vamos ver, a seguir, como obtê-las.
c0 |
cl1 |
c2 |
c3 |
c4 |
c5 |
c6 |
c7 |
c8 |
c9 |
c10 |
c11 |
c12 |
c13 |
c14 |
c15 |
c16 |
c17 |
c18 |
c19 |
|
l0 |
00 |
01 |
02 |
03 |
04 |
05 |
06 |
07 |
08 |
09 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
l1 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
l2 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
legenda: l = linha, c = coluna |
Observe a tabela acima, ela representa a distribuição do 60 primeiros inimigos.
Sabendo que o número de colunas, que chamaremos de
c
, é 20
, podemos obter o número da coluna dividindo o índice do inimigo, que chamaremos de i
por 20
. Por exemplo, o inimigo armazenado no índice 21
da lista está na coluna i/c => 20/21 = 1.
A sobra desta divisão nos dirá a que linha o inimigo pertence, portando:linha = i div c coluna = i mod cCom isto, podemos calcular os valores das coordenas X e Y, já que sabemos a altura e a largura de cada célula (o valor da constante
DEBUG_CELL_SIZE
), que chamaremos de k temosx = ( i div c ) * k y = ( i mod c ) * kE, como definimos, em nosso layout, uma margem esquerda de uma célula e uma margem superior de duas, chegamos em
x = k + (( i div c ) * k ) y = 2K + (( i mod c ) * k )
Trazendo isto para o código, vamos alterar a função TGame.CreateGameObjects para criar e distribuir corretamente os inimigos em suas posições iniciais na tela.
procedure TGame.CreateGameObjects; var i : integer; enemy : TEnemy; begin for i:= 0 to 119 do begin case i of 00..39 : begin enemy := TEnemyC.Create( fRenderer ); enemy.Sprite.Texture.Assign( fTextures[ integer(TSpriteKind.EnemyC) ] ); enemy.Sprite.InitFrames(1, 2); end; 40..79 : begin enemy := TEnemyB.Create( fRenderer ); enemy.Sprite.Texture.Assign( fTextures[ integer(TSpriteKind.EnemyB) ] ); enemy.Sprite.InitFrames(1, 2); end; 80..119 : begin enemy := TEnemyA.Create( fRenderer ); enemy.Sprite.Texture.Assign( fTextures[ integer(TSpriteKind.EnemyA) ] ); enemy.Sprite.InitFrames(1, 2); end; end; enemy.Position.X := DEBUG_CELL_SIZE + ( i mod 20 ) * DEBUG_CELL_SIZE ; enemy.Position.Y := 2* DEBUG_CELL_SIZE + ( i div 20 ) * DEBUG_CELL_SIZE ; fEnemies.Add( enemy ); end; fPlayer := TPlayer.Create( fRenderer ); fPlayer.Sprite.Texture.Assign( fTextures[ integer(TSpriteKind.Player)] ); fPlayer.Sprite.InitFrames(1,1);
fPlayer.OnShotTriggered:= @doPlayer_OnShot; fPlayer.Position.X := trunc( SCREEN_HALF_WIDTH - ( fPlayer.Sprite.Texture.W / 2 )); fPlayer.Position.Y := (DEBUG_CELL_SIZE * 18) - fPlayer.Sprite.CurrentFrame.Rect.h; fShots := TShotList.Create(true); end;
Vamos também diferenciar um inimigo do outro para que eles tenham pontos de vida diferentes.
{ TEnemyA } TEnemyA = class( TEnemy ) protected procedure InitFields; override; end; { TEnemyB } TEnemyB = class( TEnemy ) protected procedure InitFields; override; end; { TEnemyC } TEnemyC = class( TEnemy ) protected procedure InitFields; override; end; { TEnemyD } TEnemyD = class( TEnemy ) protected procedure InitFields; override; end; (...) { TEnemyC } procedure TEnemyC.InitFields; begin inherited InitFields; fHP:= 3; end; { TEnemyB } procedure TEnemyB.InitFields; begin inherited InitFields; fHP:= 2; end; { TEnemyA } procedure TEnemyA.InitFields; begin inherited InitFields; fHP := 1; end; { TEnemyD } procedure TEnemyD.InitFields; begin inherited InitFields; fHP:= 4; end;
Restringindo os movimentos
Vamos aproveitar que estamos com essas fórmulas frescas na cabeça e restringir os movimentos do jogador para que ele não saia da seção inferior do layout. Altere TPlayer.Update conforme o código a seguir.
procedure TPlayer.Update(const deltaTime: real); begin if fInput[Ord(TPlayerInput.Left)] then begin Position.X:= Position.X - ( fSpeed * deltaTime ); if Position.X < DEBUG_CELL_SIZE then Position.X:= DEBUG_CELL_SIZE; end; if fInput[Ord(TPlayerInput.Right)] then begin Position.X:= Position.X + ( fSpeed * deltaTime ); if Position.X > ((DEBUG_CELL_SIZE * 24) - Self.Sprite.CurrentFrame.Rect.w) then Position.X:= ((DEBUG_CELL_SIZE * 24) - Self.Sprite.CurrentFrame.Rect.w); end; if fCooldownCounter > 0 then Dec(fCooldownCounter, trunc(deltaTime * MSecsPerSec)); if ( (fInput[Ord(TPlayerInput.Shot)]) and ( fCooldownCounter <= 0) ) then begin if Assigned(fOnShotTriggered) then fOnShotTriggered(self); fCooldownCounter := fCooldown; fInput[Ord(TPlayerInput.Shot)] := false; end; end;
Quanto aos inimigos, vamos deixá-los parados em seus lugares por enquanto, pois isto irá facilitar muito as coisas no decorrer do próximo tópico onde iremos tratar as colisões entre os objetos. Voltaremos à movimentação dos inimigos em outro momento.
Tratamento de Colisões
Inimigos e seus retângulos de colisão |
Detecção de colisões é um assunto extenso, mas para nosso game só há um tipo de colisão possível, a colisão entre retângulos que, na prática se reduz a verificar se há alguma intersecção ente dois deles. A SDL, novamente, possui uma função para nos ajudar. Confira a documentação de SDL_HasIntersection e veja como tudo o que ela precisa é de dois retângulos. Exatamente os retângulos que definem os limites de cada frame que nossa classe de sprite possui.
Voltando a nosso código, vemos que TSpriteFrame possui uma propriedade Rect e que este retângulo define as dimensões do sprite, mas não sabe nada sobre sua posição. Vamos adicionar um novo método para resolver isto.
TSpriteFrame = class public Rect : TSDL_Rect; TimeSpan : Cardinal; //em milisegundos function GetPositionedRect( position : TPoint ) : TSDL_Rect; inline; end; (...) // retorna o retângulo do sprite transladado (ou transportado) para o ponto "position" function TSpriteFrame.GetPositionedRect(position : TPoint): TSDL_Rect; begin result.x := trunc(position.X); result.y := trunc(position.Y); result.h := self.Rect.h; result.w := self.Rect.w; end; (...) function TGameObject.GetSpriteRect: TSDL_Rect; begin result := fSprite.CurrentFrame.GetPositionedRect( self.Position ); end;
Agora podemos percorrer uma lista qualquer de objetos e verificar se eles colidem, levando um evento eqivalente. Veja.
//assinatura do evento que vamos levantar quando uma colisão for detectada TGameObjectCollisonEvent = procedure(Sender, Suspect: TGameObject; var StopChecking: boolean) of object; (...) //classe TGameObject atualizada. // note a presença o evento OnCollided e do método CheckCollisions TGameObject = class private function GetSpriteRect: TSDL_Rect; strict protected fRenderer : PSDL_Renderer; fDrawMode : TDrawMode; fOnCollided : TGameObjectCollisonEvent; protected fSprite : TSprite; procedure InitFields; virtual; public Position : TPoint; constructor Create( const aRenderer: PSDL_Renderer ); virtual; destructor Destroy; override; procedure Update(const deltaTime : real ); virtual; abstract; procedure Draw; virtual; abstract; procedure CheckCollisions( Suspects: TGameObjectList); property DrawMode: TDrawMode read fDrawMode write fDrawMode; property Sprite : TSprite read fSprite; property SpriteRect : TSDL_Rect read GetSpriteRect; property OnCollided : TGameObjectCollisonEvent read fOnCollided write fOnCollided; end; (...) //implementação da checagem de colisões procedure TGameObject.CheckCollisions( Suspects: TGameObjectList ); var i : integer; myRect : TSDL_Rect; suspectRect : TSDL_Rect; aStopCheckhing : boolean; begin aStopCheckhing := false; myRect := GetSpriteRect; for i:= 0 to Pred( Suspects.Count ) do begin suspectRect := Suspects[i].SpriteRect; if ( SDL_HasIntersection(@myRect, @suspectRect) ) = SDL_TRUE then if Assigned( fOnCollided ) then begin fOnCollided( self, Suspects[i], aStopCheckhing ); if aStopCheckhing then break; end; end; end;
CheckCollisions recebe uma lista de objetos "suspeitos" e percorre-os buscando por uma colisão se valendo de HSD_HasIntersection para tal. Se uma colisão for detectada, chamados o evento OnCollided e damos ao callback a chance de parar a com o loop de checagens através da variável aStopCheckhing.
Com a rotina de colisão implementada em TGameObject, vamos varrer todos os tiros disparados pelo jogador e verificar se colidem com algum inimigo. Poderíamos testar todas as instâncias de TShot contra todas as instâncias de TEnemy, mas um controle mais granular é desejável por uma questão de performance e, principalmente, para mantermos o código limpo e fácil de acompanhar. Para evitar isto, vamos implementar rotinas de filtro em nossas listas.
//retorna uma lista com todos os projéteis que estão seguindo a direção "aDirection" function TShotList.FilterByDirection(aDireciton: TShotDirection): TShotList; var i: integer; shot : TShot; begin result := TShotList.Create( false ); for i:=0 to Pred(Self.Count) do begin shot := TShot(Self.Items[i]); if (shot.Visible) and (shot.Direction = aDireciton) then result.Add( Self.Items[i] ); end; end; (...) //retorna uma lista com todos inimigos vivos ou todos os mortos function TEnemyList.FilterByLife(aAlive: boolean): TEnemyList; var i : integer; enemy : TEnemy; begin result := TEnemyList.Create( false ); for i:=0 to Pred( Self.Count ) do begin enemy := TEnemy(Self.Items[i]); if aAlive then if enemy.HP > 0 then result.Add( enemy ) else if enemy.HP <= 0 then result.Add( enemy ); end; end;
Finalmente podemos testar, em TGame, as colisões entre os tiros disparados realizados pelo jogador e os inimigos presentes na tela.
(* Busca por colisões entre os tiros do jogador (aqueles que segem para cima) e os inimigos ainda vivos *) 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 try shotList := fShots.FilterByDirection( TShotDirection.Up ); suspectList := fEnemies.FilterByLife( true ); for i:=0 to Pred(shotList.Count) do TShot(shotList[i]).CheckCollisions( suspectList ); finally shotList.Free; suspectList.Free; end; end; (...) (* alteramos também o game loop para incluir as checagens de colisão logo após o estado dos objetos ter sido atualizado. *) procedure TGame.Run; var deltaTime : real; thisTime, lastTime : UInt32; begin deltaTime := 0.0; thisTime := 0; lastTime := 0; fRunning := true; while fRunning do begin thisTime := SDL_GetTicks; deltaTime := real((thisTime - lastTime) / MSecsPerSec); lastTime := thisTime; CheckDevices; HandleEvents; Update( deltaTime ); CheckCollision; Render; SDL_Delay(1); end; end; (...) (* Nova implementação de TEnemy.Draw para introduzir o modo de desenho de debug que, além do inimigo, exibe na tela seu próprio retângulo de colisão. O inimigo também é desenhado com uma cor diferente dependendo da quantidade de HP que lhe resta *) procedure TEnemy.Draw; var source, destination : TSDL_Rect; begin source := Sprite.CurrentFrame.Rect; destination := Sprite.CurrentFrame.GetPositionedRect(self.Position); SDL_SetTextureColorMod( fSprite.Texture.Data, 255, 255, 255); if ( HP > 0 ) and ( fSprite.Texture.Data <> nil ) then begin if self is TEnemyB then case HP of 1 : SDL_SetTextureColorMod( fSprite.Texture.Data, 255, 0, 0); end; if self is TEnemyC then case HP of 2 : SDL_SetTextureColorMod( fSprite.Texture.Data, 255, 0, 0); 1 : SDL_SetTextureColorMod( fSprite.Texture.Data, 200, 0, 0); end; SDL_RenderCopy( fRenderer, fSprite.Texture.Data, @source, @destination) ; if fDrawMode = TDrawMode.Debug then begin SDL_SetRenderDrawColor( fRenderer, 0, 255, 0, 255 ); SDL_RenderDrawRect( fRenderer, @destination ); end; end; end;
Por fim, vamos tratar o evento disparado quando uma colisão for encontrada, infringindo o dano no inimigo, destruindo o projétil e adicionando pontos ao score (10 para cada hit e 100 para cada morte).
//amarramos o evento OnCollided de cada tiro disparado no momento de sua criação procedure TGame.doPlayer_OnShot(Sender: TGameObject); var player : TPlayer; shot : TShot; begin if (Sender is TPlayer) then begin player := TPlayer(Sender); shot := TShot.Create( fRenderer ); shot.Sprite.Texture.Assign( fTextures[ Ord(TSpriteKind.ShotA) ] ); shot.Sprite.InitFrames( 1,1 ); shot.Position := player.ShotSpawnPoint; shot.Position.X -= (shot.Sprite.CurrentFrame.Rect.w / 2); shot.OnCollided := @doShots_OnCollided; shot.DrawMode := GetDrawMode; fShots.Add( shot ); end; end; (...) //e implementamos o evento em TGame procedure TGame.doShots_OnCollided(Sender, Suspect: TGameObject; var StopChecking: boolean); var shot : TShot; enemy : TEnemy; begin if ( Sender is TShot ) then begin if (Suspect is TEnemy) and (TEnemy(Suspect).HP > 0) then begin shot := TShot(Sender); enemy := TEnemy(Suspect); enemy.Hit( 1 ); if enemy.Alive then Inc(fScore, 10) else Inc(fScore, 100); //destruímos o tiro fShots.Remove( shot ); //o tiro foi destruído, não precisamos mais seguir com o loop de checagens StopChecking := true; end; end; end;
Fontes TTF
Para exibir os pontos que o jogador acumulou ao acertar os inimigos, precisamos ser capazes de exibir texto na tela. Poderíamos criar nossa própria lógica para exibição de fontes em bitmap no game, mas há uma solução mais prática: podemos utilizar uma das inúmeras fontes True Type disponíveis na internet.
A SDL não suporta fontes de maneira nativa. Precisamos recorrer a outa de suas extensões o SDL TTF (TTF é abreviatura de True Type Font). O processo de integração já é o meso que fizemos com o SDL_Image, baixe as dlls no site do projeto, descompacte-os no diretório.\bind e proto, podemos inicializar a biblioteca chamando a função TTF_Ini.
rocedure TGame.Initialize; var flags, result: integer; begin if ( SDL_Init( SDL_INIT_VIDEO or SDL_INIT_TIMER or SDL_INIT_JOYSTICK ) <> 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 ); 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( TTF_GetError ); LoadTextures; CreateFonts; CreateGameObjects; fGameText := TGameTextManager.Create( fRenderer ); end; (...) procedure TGame.CreateFonts; const FONTS_DIR = '.\assets\fonts\'; begin fGameFonts := TGameFonts.Create( fRenderer ); fGameFonts.LoadFonts( FONTS_DIR ); end;
Note a presença da rotina CreateFonts e do tipo SDLTTFException na nova versão de TGame.Initialize. Também há uma nova classe: TGameFonts que vamos criar para encapsular a lógica de criação das fontes.
Além de uma classe para gerenciar as fontes, vamos precisar de mais uma para encapsular a lógica de desenho dos textos. Busque no código do projeto de exemplo pela unit sdlGameText e você encontrará a implementação de todas estas classes.
Não vamos explicar os detalhes da implementação aqui para não ficar muito massante, já que o código é muito parecido com o que desenvolvemos para carregar as imagens dos sprites. O que você precisa saber é que ele vai buscar os arquivos de fontes no diretório '.\assets\fonts' então, caso não ainda não os tenha, baixe o pacote com os assets atualizados e utilize as fontes ttf que estão disponíveis.
HUD
Vamos atualizar TGame.Render para incluir os elementos do HUD.
(* render alterado para desenhar o GUI, ou HUD *) procedure TGame.Render; begin SDL_SetRenderDrawColor( fRenderer, 0, 0, 0, SDL_ALPHA_OPAQUE ); SDL_RenderClear( fRenderer ); DrawGameObjects; DrawDebugInfo; DrawGUI; SDL_RenderPresent( fRenderer ); fFrameCounter.Increment; end; (* rotina atualizada para exibir um texto, além do led, indicando o status do joystick *) procedure TGame.DrawDebugInfo; var source, dest : TSDL_Rect; begin DrawDebugGrid; //desenha o led do sensor do controle dest.x := 10; dest.y := DEBUG_CELL_SIZE div 2; dest.w := 16; dest.h := 16; source.y := 0; source.w := 16; source.h := 16; if SDL_NumJoysticks > 0 then begin source.x := 0; //desenha o texto utilizando a fonte "DebugNormal", veja sdlGameText para mais detalhes fGameText.Draw('GAME CONTROLLER FOUND', 32, 19, fGameFonts.DebugNormal); end else begin source.x := 16; //desenha o texto utilizando a fonte "DebugError", veja sdlGameText para mais detalhes fGameText.Draw('GAME CONTROLLER NOT FOUND', 32, 19, fGameFonts.DebugError); end; SDL_RenderCopy( fRenderer, fTextures[Ord(TSpriteKind.Leds)].Data, @source, @dest); end; (* Finalmente, desenhamos a linha que limita a nossa barra de informações e desenhos o texto com a pontuação do jogador centralizada na tela *) procedure TGame.DrawGUI; 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)); fGameText.Draw( Format('SCORE %.6d', [fScore]), 290, 12, fGameFonts.GUI ); end;
Bom, com isto finalizamos a terceira parte da série.
Se você chegou até aqui, meus parabéns. Foi um bocado de informação nova, eu sei, mas está valendo a pena. O projeto está começando a parecer um game de verdade e estamos a um passo de dar vida aos inimigos e tocar alguns efeitos sonoros!
Muito obrigado e até a próxima!