O meu primeiro Jogo em MonoGame

Muitos programadores chegaram ao mundo da programação através do fascínio do desenvolvimento de jogos. Desde as cassetes de ZX Spectrum, que demoravam eternidades a carregar e a criação de jogos era uma tarefa muitas vezes hercúlea, até aos dias de hoje, a criação de jogos percorreu um longo caminho e hoje podemos encontrar várias plataformas dedicadas ao seu desenvolvimento.

Para facilitar a criação de jogos para múltiplas plataformas foi criada a framework MonoGame, baseada na framework XNA da Microsoft, que apresenta uma grande facilidade de aprendizagem. Seguindo o princípio “Escreve uma vez, corre em todo o lado”, ao desenvolvermos um jogo com MonoGame, ele irá correr em iOS, Android, Mac OS X, tvOS, Windows, Linux, Playstation4 e mais.

Neste artigo vamos criar um jogo do princípio ao fim, passo a passo, desde a criação do interface de utilizador até ao adicionar da lógica de jogo.

À semelhança do Jogo da Toupeira (Whack-a-Mole), o objectivo do jogo é tocar nos bonecos à medida que vão aparecendo, evitando que fujam com as revistas. Se um boneco ficar sem ser tocado por mais de cinco segundos, ele leva as revistas e o jogo acaba.

Ficheiro → Novo Jogo

Antes de dar início ao desenvolvimento do jogo, devemos verificar se temos o Xamarin instalado bem como a última build de desenvolvimento do MonoGame. Os passos que vamos utilizar aplicam-se tanto a Xamarin Studio como a MonoGame no Visual Studio.

Vamos primeiro criar um projecto partilhado onde iremos guardar toda a lógica de jogo, podendo este ser também partilhado com todas as plataformas alvo em que queremos correr o jogo. Dentro do Xamarin Studio, escolhemos File > New Solution > MonoGame > Library > MonoGame Shared Library, e damos nome ao projecto PapTap. Não nos podemos esquecer de adicionar projectos para todas as plataformas que queremos para o jogo.

Adicionamos um projecto MonoGame for Android Application e damos-lhe o nome PapTap.Android. Uma vez que o nosso jogo não irá tirar partido das configurações nativas do Android, como o NFC e outros componentes, podemos apagar a classe Game1 criada no projecto Android e adicionar uma referência ao projecto partilhado que criamos antes.

Para correr a aplicação no emulador seleccionado ou no Xamarin Android Player, pressionamos F5 ou Cmd+Enter. Deverá surgir um lindo ecrã azul.

Agora que configurámos a nossa solução devidamente, vamos começar a escrever o nosso jogo.

Anatomia de um Jogo

Para criar um jogo é necessário que vários componentes trabalhem em conjunto. A classe Game1 contém toda a lógica do jogo e é constituída por cinco métodos principais: Constructor, Initialize, LoadContent, Update, e Draw.

Cada um deles tem o objectivo de garantir que o jogo funciona devidamente, desde os efeitos sonoros à resposta ao input do utilizador e à execução da lógica do jogo.

Os métodos Constructor e Initialize são utilizados para desempenhar qualquer inicialização que o jogo necessite antes de começar a correr. O LoadContent tem a função de carregar qualquer conteúdo do jogo tal como texturas, sons, sombras, e outros componentes gráficos ou sonoros. O Update é utilizado para actualizar qualquer lógica de jogo enquanto o jogo é executado (recolher o input do utilizador ou actualizar o mundo), enquanto que o Draw deve ser utilizado exclusivamente para desenhar quaisquer gráficos que precisem ser exibidos.

Adicionar Items de Jogo

Para um jogo estar completo, necessita de texturas e efeitos sonoros. Estes são conhecidos no desenvolvimento de jogos como “content” ou “assets”. A maior parte das frameworks de jogos possui uma pipeline de conteúdo, a qual é utilizada apenas para pegar em items que estão em bruto e transformá-los num formato optimizado para o jogo. Na pasta Content, o ficheiro Content.mgcb é a pipeline de conteúdo do MonoGame. Tudo o que for adicionado a este ficheiro será optimizado e incluído no pacote final da aplicação.

Mais uma vez, todos os itens do jogo podem ser partilhados entre as plataformas alvo. Assim, arrastamos o Content Directory do projecto Android para o Projeto Partilhado. Devemos certificar-nos que a Build Action do ficheiro Content.mcgb está definida para MonoGameContentReference. Se a Build Action não aparecer nas opções clicamos com o botão direito do rato no ficheiro Content.mcgb, selecionamos Propriedades e inserimos manualmente o texto toMonoGameContentReference. Em seguida descarregamos os itens PapTap e fazemos a extração para a pasta Content do Projeto Partilhado.

No MonoGame encontramos um Editor de Pipeline especial que facilita imenso o trabalho com os itens do jogo.

Fazemos duplo clique no ficheiro Content.mcgb para abrir o editor de Pipeline e adicionamos os ficheiros de items que acabámos de descarregar.

MonoGame: pipeline

Agora que o conteúdo do jogo está optimizado para uso na nossa aplicação, podemos usar os itens no nosso jogo.

Criando o Interface de Utilizador do PapTap

Para carregar os itens de jogo recorremos a um ContentManager que é exposto por default através da propriedade Content da classe Game. Para começar vamos importar alguns namespaces necessários e declarar campos para armazenar os itens de jogo.

using System.Collections.Generic;
using Microsoft.Xna.Framework.Audio;
using Microsoft.Xna.Framework.Input.Touch;
using Microsoft.Xna.Framework.Media;

public class Game1 : Game {
  GraphicsDeviceManager graphics;
  SpriteBatch spriteBatch;
  Texture2D monkey;
  Texture2D background;
  Texture2D logo;
  SpriteFont font;
  SoundEffect hit;
  Song title;
}

De seguida é necessário carregar os itens. Para isso adicionamos o seguinte código ao método LoadContent, pois é onde todos os itens devem ser carregados em MonoGame.

monkey = Content.Load ("monkey");
background = Content.Load("background");
logo = Content.Load("logo");
font = Content.Load("font");
hit = Content.Load("hit");
title = Content.Load("title");
MediaPlayer.IsRepeating = true;
MediaPlayer.Play(title);

Agora que carregámos o nosso conteúdo, desde as texturas ao áudio, é hora de desenhar a interface do utilizador no ecrã. Para desenhar imagens 2D e texto usamos a classe SpriteBatch. Para fazer o rendering de forma eficiente, o desenho é compactado junto e as sprites devem ser desenhadas entre os métodos da SpriteBatch, Begin e End. De seguida atualizamos o método Draw para desenhar a textura do boneco no ecrã, utilizando o campo SpriteBatch que acabámos de criar.

protected override void Draw(GameTime gameTime) {
  graphics.GraphicsDevice.Clear(Color.CornflowerBlue);
  spriteBatch.Begin();
  spriteBatch.Draw(monkey, Vector2.Zero);
  spriteBatch.End();
  base.Draw(gameTime);
}

Para correr a aplicação carregamos Ctrl+Enter ou na tecla F5. Deveremos ver o boneco com a revista e ouvir a música que definimos no LoadContent. Agora que já temos os itens de jogo a carregar, vamos construir o restante do interface do utilizador para jogar PapTap.

Construindo o Interface do Utilizador do PapTap

Habitualmente nos jogos tradicionais do estilo Whack-a-Mole aparecem toupeiras aleatoriamente no ecrã e devem ser clicadas para que desapareçam. Em vez de fazermos o rendering aleatório de bonecos no ecrã, podemos utilizar uma grelha para ajudar e garantir que os bonecos não se sobrepõem de forma a que a experiência do utilizador seja consistente. A grelha é constituída por várias células. Cada célula contém um rectângulo, cor, temporizador decrescente, e valor de transição que será utilizado para fazer o fade in do boneco. Vamos então copiar e colar para o ficheiro Game1.cs a seguinte classe GridCell.

public class GridCell {
  public Rectangle DisplayRectangle;
  public Color Color;
  public TimeSpan CountDown;
  public float Transition;

  public GridCell() {
    Reset();
  }

  public bool Update(GameTime gameTime) {
    if (Color == Color.White) {
      Transition += (float) gameTime.ElapsedGameTime.TotalMilliseconds / 100f;
      CountDown -= gameTime.ElapsedGameTime;
      if (CountDown.TotalMilliseconds <= 0) {
        return true;
      }
    }
    return false;
  }

  public void Reset() {
    Color = Color.TransparentBlack;
    CountDown = TimeSpan.FromSeconds(5);
    Transition = 0f;
  }

  public void Show() {
    Color = Color.White;
    CountDown = TimeSpan.FromSeconds(5);
  }
}

Para reiniciar a célula ao seu estado default onde o boneco está escondido utilizamos o método Reset, o qual é chamado pelo utilizador ao clicar num boneco. Este método é utilizado para definir o temporizador para cinco segundos quando o boneco aparece no ecrã. O método Update é chamado em cada frame para atualizar o temporizador decrescente, ajudando a perceber se o utilizador não clicou no boneco dentro da janela de tempo dos cinco segundos. Uma vez definidas as células, vamos definir a grelha. Começamos por criar um novo campo List chamado grelha (grid).

List grid = new List();

Para calcular os rectângulos de exibição para cada célula, adicionamos o seguinte código ao método LoadContent.

var viewport = graphics.GraphicsDevice.Viewport;
var padding = (viewport.Width / 100);
var gridWidth = (viewport.Width - (padding * 5)) / 4; var gridHeight = gridWidth;

for (int y = padding; y < gridHeight*5; y += gridHeight+padding) {
  for (int x = padding; x < viewport.Width-gridWidth; x += gridWidth+padding) {
    grid.Add (new GridCell() {
        DisplayRectangle = new Rectangle(x, y, gridWidth, gridHeight)
    });
  }
}

Se quisermos que o jogo pareça bem em todos os fatores de forma, devemos ter em conta o tamanho do ecrã em vez dos valores posicionais definidos no código. O GraphicsDevice.ViewPort proporciona-nos uma forma dinâmica de trabalhar com diferentes factores de forma. No código acima, adicionámos 10% da largura do ecrã como espaçamento entre as células e calculámos uma largura e altura para a grelha utilizando a mesma propriedade. Podemos fazer loop através das coordenadas (X,Y) para cada linha e coluna e calcular o rectângulo de exibição.

Substituímos o método Draw com o código para desenhar os nossos bonecos.

protected override void Draw(GameTime gameTime) {
  graphics.GraphicsDevice.Clear(Color.SaddleBrown);
  spriteBatch.Begin();
  foreach (var square in grid)
    spriteBatch.Draw(monkey, destinationRectangle: square.DisplayRectangle, color: Color.White);
  spriteBatch.End();
  base.Draw(gameTime);
}

Por fim, queremos que o nosso jogo corra apenas em retrato, por isso adicionamos o seguinte código ao construtor.

graphics.SupportedOrientations = DisplayOrientation.Portrait;

Corremos a aplicação e deveremos ver uma grelha cheia de bonecos!

MonoGame: grelha inicial do PapTap

Posto isto, o interface de utilizador para o nosso jogo construído com MonoGame está completo.

Adicionando Lógica de Jogo

Assim que tenhamos uma interface de utilizador construída para o nosso jogo, o próximo passo será adicionar a lógica necessária para que possa ser jogado. Precisamos de actualizar a grelha de bonecos para permitir aos utilizadores interagirem com ela para jogar o jogo. Primeiro definimos um enumaration GameState no Projeto Partilhado PapTap com os seguintes valores.

enum GameState {
  Start,
  Playing,
  GameOver
}

Copiamos e colamos os seguintes campos class-level na classe Game1.

// Define o estado inicial do jogo
GameState currentState = GameState.Start;
Random rnd = new Random();

// Texto a ser apresentado ao utilizador
string gameOverText = "Game Over";
string tapToStartText = "Toque para iniciar";
string scoreText = "Pontuação : {0}";

TimeSpan gameTimer = TimeSpan.FromMilliseconds(0);
TimeSpan increaseLevelTimer = TimeS- pan.FromMilliseconds(0);
TimeSpan tapToRestartTimer = TimeSpan.FromSeconds (2);

int cellsToChange = 0;
int maxCells = 1;
int maxCellsToChange = 14;
int score = 0;

Estes campos têm como utilidade seguir os vários estados em que o jogo pode estar. Ao continuar a jogar o PapTap, mais e mais células serão alteradas durante um dado nível, tornando o jogo mais difícil. Agora que o conjunto de configurações necessárias para seguir o estado do jogo está fora do caminho, é tempo de começar a implementar a nossa lógica de jogo!

Processar o Touch do Utilizador

A maior parte da mecânica de jogo está no tratamento do input do utilizador e por isso é importante selecionar um motor de jogo que consiga tratar todos os tipos de input. No MonoGame podemos encontrar um grande conjunto de controlos de input, sendo um deles TouchPanel.

O método GetState do TouchPanel desenvolve uma colecção de localizações touch e o seu estado: Pressed, Moved e Released. Isto permite o acompanhamento dos toques do utilizador no ecrã. Se o utilizador tocar no ecrã, iremos percorrer todas as células da grelha e verificar se essa localização se interseta com o retângulo de exibição da célula. No caso de isso acontecer e o boneco estiver a ser exibido nessa altura, tocamos um som, reiniciamos a célula e incrementamos a pontuação do utilizador, afinal ele impediu que o boneco fugisse com a revista. Adicionamos o método PressTouches abaixo à classe Game1.

void ProcessTouches(TouchCollection touchState) {
  for (var touch in touchState) {
    if (touch.State != TouchLocationState.Released)
      continue;
    for (int i = 0; i < grid.Count; i++) {
      if(grid[i].DisplayRectangle.Contains(touch.Position)
        && grid[i].Color == Color.White) {
      hit.Play();
      grid[i].Reset();
      score += 1;
    }
  }
}

Verificar se o Jogo Acabou

No PapTap os bonecos aparecem por cinco segundos de cada vez e caso não sejam clicados durante esse tempo o jogo acaba. Para verificar se o jogo realmente termina, podemos percorrer todos os itens na grelha dos bonecos e chamar o método Update que devolve true no caso de um boneco estar a ser mostrado por cinco segundos. Nesse caso significa que o utilizador não clicou no boneco na janela de tempo permitida, por isso devemos alterar o GameState para GameOver e começar o tapToRestartTimer, o que impede o jogo de reiniciar imediatamente (e o jogador não ver a sua pontuação) com um click aleatório após o jogo ter terminado. Adicionamos assim o método CheckForGameOver à classe Game1.

void CheckForGameOver(GameTime gameTime) {
  for (int i = 0; i < grid.Count; i++) {
    if (grid[i].Update(gameTime)) {
      currentState = GameState.GameOver;
      tapToRestartTimer = TimeS- pan.FromSeconds(2);
      break;
    }
  }
}

Calcular os Bonecos a Mostrar por Nível

A maioria dos jogos, o PapTap aumenta em dificuldade conforme o jogo continua. Cada nível irá mostrar mais e mais bonecos, portanto precisamos de calcular exactamente quantas células precisamos de exibir. O gameTimer que criámos anteriormente é utilizado para acompanhar quanto tempo decorreu desde que este nível começou. Podemos incrementar o temporizador acedendo a gameTime.ElapseGameTime que tem a quantidade de tempo desde que o último Update foi chamado. Uma vez que este temporizador passa os dois segundos, redefinimo-lo para zero e depois calculamos o número de células a alterar até um número máximo. Adicionamos o método CalculateCellsToChange à classe Game1.

void CalculateCellsToChange(GameTime gameTime) {
  gameTimer += gameTime.ElapsedGameTime;
  if (gameTimer.TotalSeconds > 2) {
    gameTimer = TimeSpan.FromMilliseconds(0);
    cellsToChange = Math.Min(maxCells, maxCellsToChange);
  }
}

Calcular o Nível de Dificuldade

Como já vimos em CalculateCellsToChange, maxCells define o número máximo de macacos a mostrar num nível particular; por defeito, este valor está definido para 1. À medida que o jogo progride, queremos que o valor aumente com o tempo. Para fazer isto, vamos usar um temporizador para seguir quanto tempo passou no nível, e pós 10 segundos, avançar para outro nível e incrementar o número máximo de bonecos exibidos. Como resultado, o PapTap mostrará um boneco extra a cada 10 segundos. Adicionamos o método IncreaseLevel à classe Game1.

void IncreaseLevel(GameTime gameTime) {
  increaseLevelTimer += gameTime.ElapsedGameTime;
  if (increaseLevelTimer.TotalSeconds > 10) {
    increaseLevelTimer = TimeSpan.FromMilliseconds(0);
    maxCells++;
  }
}

Mostrar Bonecos

Finalmente, temos que tornar visíveis os bonecos que estão invisíveis por defeito. Para evitar que o PapTap seja previsível, podemos usar a classe Random para seleccionar um boneco aleatório na grelha. Se o boneco não está já a aparecer, podemos exibi-lo e decrementar o número de células necessário para mudar para esse nível.

void MakeMonkeysVisible() {
  if (cellsToChange > 0) {
    var idx = rnd.Next(grid.Count);
    if (grid[idx].Color == Color.TransparentBlack) {
      grid[idx].Show();
      cellsToChange--;
    }
  }
}

Juntando as Peças

Agora que temos todas as peças individuais completas, vamos juntá-las num método chamado PlayGame.

void PlayGame(GameTime gameTime, TouchCollection touchState) {
  ProcessTouches(touchState);
  CheckForGameOver(gameTime);
  CalculateCellsToChange(gameTime);
  MakeMonkeysVisible();
  IncreaseLevel(gameTime);
}

Geralmente a ordem não tem importância. Contudo, normalmente queremos verificar o input do utilizador primeiro para tornar o jogo mais responsivo e garantir que o boneco esteve no ecrã por cerca de cinco segundos completos.

Executando a Lógica de Jogo

Uma vez completa a maior parte da lógica de jogo para o PapTap, precisamos de uma forma de atualizar continuamente o jogo à medida que ele é executado. O Update é utilizado para atualizar qualquer lógica de jogo que tenhamos enquanto o jogo executa, portanto é aí que devemos colocar o método PlayGame. Vamos substituir o código atual do método Update pelo código abaixo.

protected override void Update(GameTime gameTime) {
  #if !__IOS__ && !__TVOS__
  if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonSta- te.Pressed
      || Keyboard.GetState().IsKeyDown(Keys.Escape)) {
    Exit();
  }
  #endif

  var touchState = TouchPanel.GetState();
  switch (currentState) {
    case GameState.Start:
      if (touchState.Count > 0) {
        currentState = GameState.Playing;
      }
      break;
    case GameState.Playing:
      PlayGame(gameTime, touchState);
      break;
    case GameState.GameOver:
      tapToRestartTimer -= gameTime.ElapsedGameTime;
      if (touchState.Count > 0 && tapToRestartTimer.TotalMilliseconds < 0) {
        currentState = GameState.Start;
        score = 0;
        increaseLevelTimer = TimeSpan.FromMilliseconds(0);
        gameTimer = TimeSpan.FromMilliseconds(0);
        cellsToChange = 1;
        maxCells = 1;
        for (int i = 0; i < grid.Count; i++) {
          grid[i].Reset();
        }
      }
      break;
  }
  base.Update(gameTime);
}

Primeiro, chamamos o método TouchPanel.GetState para agarrar o actual estado do touch. Em seguida vamos passá-lo para o método PlayGame, que irá usá-lo para tratar o input do utilizador. A declaração de troca controla o estado do jogo. Como já vimos, vamos por default para GameState.Start. O caso GameState.Playing simplesmente chama a lógica de jogo que escrevemos anteriormente, enquanto o caso GameState.GameOver verifica para ver se o utilizador clicou para reiniciar o jogo e, se sim, reiniciar todos os nosso campos ao valor inicial, limpando a grelha, e transitar de volta para o estado GameState.Start.

Desenhando o PapTap

Se corrermos o PapTap agora, não vemos muito mais do que a grelha de macacos que vimos quando começámos. Apesar da lógica de jogo estar a correr nos bastidores, a interface do utilizador não está a ser actualizada após o jogo começar a correr. O lugar certo para toda esta lógica é o método Draw, de deve ser usado exclusivamente para desenhar quaisquer gráficos que precisamos de exibir.

protected override void Draw(GameTime gameTime) {
  graphics.GraphicsDevice.Clear(Color.SaddleBrown);
  var center = graphics.GraphicsDevice.Viewport. Bounds.Center.ToVector2();
  var half = graphics.GraphicsDevice.Viewport.Width / 2;
  var aspect = (float)logo.Height / logo.Width;
  var rect = new Rectangle((int)center.X - (half / 2), 0, half, (int)(half * aspect));
  spriteBatch.Begin();
  spriteBatch.Draw(background, destinationRectangle: graphics.GraphicsDevice.Viewport.Bounds, color: Color.White);
  spriteBatch.Draw(logo, destinationRectangle: rect, color: Color.White);
  foreach (var square in grid) {
    spriteBatch.Draw(monkey, destinationRectangle: square.DisplayRectangle, color: Color.Lerp(Color.TransparentBlack, square.Color, square.Transition));
  }
  if (currentState == GameState.GameOver) {
    var v = new Vector2(font.MeasureString (gameOverText).X / 2, 0);
    spriteBatch.DrawString(font, gameOverText, center - v, Color.OrangeRed);
    var t = string.Format(scoreText, score);
    v = new Vector2(font.MeasureString(t).X / 2, 0);
    spriteBatch.DrawString(font, t, center + new Vector2(-v.X, font.LineSpacing), Color.White);
  }
  if (currentState == GameState.Start) {
    var v = new Vector2(font.MeasureString(tapToStartText).X / 2, 0);
   spriteBatch.DrawString(font, tapToStartText, center - v, Color.White);
  }
  spriteBatch.End();
  base.Draw(gameTime);
}

Algo importante a lembrar quando construímos aplicações e jogos é que devemos ter em conta vários tamanhos de ecrã e factores de forma dos equipamentos, desde telefones móveis a ecrãs de TV. Ao contrário do desenvolvimento de aplicações, não nos são fornecidos motores de layout dinâmicos como AutoLayout ou contentores de layout como LinearLayout do Android para exibir dinamicamente itens de jogo. A propriedade ViewPort expõe propriedades para nos ajudar a descobrir quão grande uma célula deve ser no ecrã.

O texto deve estar centrado no ecrã para que possamos calcular o centro usando o seguinte.

var center = graphics.GraphicsDevice.Viewport.Bounds.Center.ToVector2();

A propriedade ViewPort.Bounds é um tipo de rectângulo que contém uma propriedade Center que pode ser usada para desenhar texto no centro do ecrã.

var v = new Vector2(font.MeasureString(tapToStartText).X / 2, 0);
spriteBatch.DrawString(font, tapToStartText, center - v, Color.White);

Podemos utilizar SpriteFont.MeasureString para calcular o tamanho do texto, o que devolve um vetor com a largura (X) e altura (y). Podemos depois tirar esse valor do centro, para que quando desenharmos a string ela termine no lugar errado. SpriteFont também expõe uma propriedade útil chamada LineSpacing, que podemos utilizar para garantir que quando desenhamos o texto verticalmente, este está devidamente espaçado. Utilizamos isto para exibir a pontuação do utilizador abaixo do texto “Game Over”.

Vamos agora correr o jogo e deveremos ter um jogo PapTap completamente funcional para Android. Se corrermos o código, deveremos ser capazes de jogar o jogo até ao fim.

Publicado na edição 53 (PDF) da Revista PROGRAMAR.