Get Even More Visitors To Your Blog, Upgrade To A Business Listing >>

Criando um Game Completo - Parte 4

Game rodando em debug no Lázarus
Bem vindo à quarta 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 joystics e uma tabela de scores online!

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
Vamos começar implementando a movimentação dos inimigos pela tela. Assim como no Space Invaders original, os alienígenas irão se mover, ordenadamente, em um padrão pré-definido.

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
Com as regras da máquina de estados que rege o movimento dos inimigos bem entendidas, podemos pensar em sua implementação. Se você tiver um pensamento mais visual, a imagem ao lado, que mostra graficamente o fluxo da máquina correspondente aos dados desta tabela, pode ajudar a visualizar melhor o seu funcionamento.

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 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.

  1. 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.
  2. 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;


A cada intervalo de tempo definida em SHOT_DELAY, verificamos se o inimigo pode atirar através de fCanShot e, se puder, testamos um número aleatório num range de 100 números e verificamos, em seguida, se ele é menor ou igual a 10. Isto nos diz que há 10% de chance de um inimigo vivo disparar o evento OnShot a cada SHOT_DELAYms.

Ó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
Ao morrer, um inimigo simplesmente some da tela, gerando um efeito visual muito pobre. Vamos melhorar isto criando uma uma pequena animação utilizando um sprite que já carregamos para memória mas ainda não utilizamos. O sprite de explosão.

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.

O que queremos, é fazer com que um sprite de explosão seja exibido no lugar do inimigo abatido. Este sprite ficará visível por um tempo e depois começará a sumir gradualmente até desaparecer por completo.

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
Até agora o jogo não pode perdido, não pode ser ganho nem pode ser pausado. Se pararmos pra pensar, veremos que estes são estados fundamentais de qualquer jogo, mas até agora só temos um: o estado em que o game se encontra quando é possível jogá-lo.

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 quanto o botão do joystick para pausar e retomar o game.

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 e do botão .
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( 'press to 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.



Links

  • Código fonte - GitHub
  • Código fonte - download direto
  • Executável - win32




This post first appeared on Computação Gráfica E Jogos Em Pascal, please read the originial post: here

Share the post

Criando um Game Completo - Parte 4

×

Subscribe to Computação Gráfica E Jogos Em Pascal

Get updates delivered right to your inbox!

Thank you for your subscription

×