2.4 Jogo da Velha

Usando o projeto firstapp como base, faremos nesta seção um “Jogo da Velha” com interface composta apenas de widgets da ImGui. Com isso ficaremos mais familiarizados com a ImGui e entraremos em contato com novas funções da biblioteca, tais como:

  • ImGui::BeginTable e ImGui::EndTable para fazer tabelas;
  • ImGui::Spacing para adicionar espaçamentos verticais;
  • ImGui::PushFont e ImGui::PopFont para usar novas fontes.

A ideia principal é simular o tabuleiro do jogo com um arranjo de 3x3 botões:

  • O jogo começará com os botões vazios, sem texto. Cada vez que um botão for pressionado, seu texto será substituído por um X ou O de acordo com o turno do jogador. Para simplificar não jogaremos contra o computador: o jogo só funcionará no modo “humano versus humano”;
  • Internamente manteremos um arranjo contendo o estado do jogo para determinar se houve um vencedor ou se “deu velha” (empate);
  • Usaremos um widget de texto estático para mostrar o turno atual e o resultado do jogo;
  • Incluiremos também um botão e uma opção de menu para reiniciar o jogo.

O resultado ficará como a seguir:

Configuração inicial

  1. Nosso projeto será chamado tictactoe. Em abcg/examples, crie o subdiretório abcg/examples/tictactoe.

  2. Abra o arquivo abcg/examples/CMakeLists.txt e acrescente a linha add_subdirectory(tictactoe). Para evitar que os projetos anteriores continuem sendo compilados, comente as linhas anteriores. O resultado ficará assim:

    # add_subdirectory(helloworld)
    # add_subdirectory(firstapp)
    add_subdirectory(tictactoe)
  3. Crie o arquivo abcg/examples/tictactoe/CMakeLists.txt. O conteúdo é o mesmo do projeto anterior. A única mudança é o nome do projeto:

    project(tictactoe)
    add_executable(${PROJECT_NAME} main.cpp window.cpp)
    enable_abcg(${PROJECT_NAME})
  4. Em abcg/examples/tictactoe, crie o subdiretório assets. Nas aplicações usando a ABCg, o subdiretório assets é utilizado para armazenar arquivos de recursos utilizados em tempo de execução (arquivos de fontes, imagens, sons, etc). No nosso caso, colocaremos em assets o arquivo de fonte TrueType Inconsolata-Medium.ttf que será utilizado para o texto dos Xs e Os. O arquivo pode ser baixado, ou simplesmente copiado de abcg/abcg/assets (essa também é a fonte padrão da ABCg).

Importante

Sempre que um projeto da ABCg é configurado pelo CMake, o diretório assets é copiado automaticamente para o diretório do executável (build/bin/proj, onde proj é o nome do projeto).

Toda vez que um arquivo de assets for modificado, é necessário limpar o diretório build para forçar a cópia de assets para build/bin/proj na próxima compilação. Isso pode ser feito de diferentes maneiras:

  • Removendo o diretório build antes de compilar novamente;
  • No VS Code, usando o comando “CMake: Clean Rebuild” da paleta de comandos (Ctrl+Shift+P);
  • Construindo o projeto através da linha de comando com build.sh/build.bat.
  1. Em abcg/examples/tictactoe, crie os arquivos main.cpp, window.cpp e window.hpp. Vamos editá-los a seguir.

main.cpp

O conteúdo de main.cpp é praticamente o mesmo de nossa primeira aplicação. A única diferença é o título da janela e seu tamanho inicial, que agora será 600x600.

#include "window.hpp"

int main(int argc, char **argv) {
  try {
    abcg::Application app(argc, argv);

    Window window;
    window.setWindowSettings(
        {.width = 600, .height = 600, .title = "Tic-Tac-Toe"});

    app.run(window);
  } catch (std::exception const &exception) {
    fmt::print(stderr, "{}\n", exception.what());
    return -1;
  }
  return 0;
}

window.hpp

Aqui definiremos nossa classe Window, responsável pelo gerenciamento da janela da aplicação e também da lógica do jogo. O conteúdo ficará como a seguir:

#ifndef WINDOW_HPP_
#define WINDOW_HPP_

#include "abcgOpenGL.hpp"

class Window : public abcg::OpenGLWindow {
protected:
  void onCreate() override;
  void onPaintUI() override;

private:
  static int const m_N{3}; // Board size is m_N x m_N

  enum class GameState { Play, Draw, WinX, WinO };
  GameState m_gameState;

  bool m_XsTurn{true};
  std::array<char, m_N * m_N> m_board{}; // '\0', 'X' or 'O'

  ImFont *m_font{};

  void checkEndCondition();
  void restartGame();
};

#endif

Em comparação com o projeto firstapp, desta vez não substituímos o método onPaint. Podemos fazer isso pois todo o conteúdo da janela será composto por controles de UI desenhados em onPaintUI.

Nossa aplicação precisa de algumas variáveis para armazenar o estado do jogo. Na linha 12, m_N é o tamanho dos lados do tabuleiro. O Jogo da Velha é jogado em um tabuleiro 3x3, mas podemos mudar esse valor para jogar com um tabuleiro 4x4, 5x5, etc. O código é genérico o suficiente para permitir isso.

Na linha 14 definimos GameState como uma enumeração de todos os possíveis estados do jogo. Os estados serão interpretados da seguinte maneira:

  • GameState::Play é quando a partida está sendo jogada. Nesse estado o jogador do turno atual poderá clicar em algum lugar do tabuleiro para colocar um X ou O;
  • GameState::Draw é quando o jogo acabou e “deu velha”;
  • GameState::WinX é quando o jogo acabou e X ganhou;
  • GameState::WinO é quando o jogo acabou e O ganhou.

O estado atual será indicado por m_gameState na linha 15.

Na linha 17, m_XsTurn é uma variável que indica se o turno atual é do X.

Na linha 18, m_board é o estado do tabuleiro, definido como um arranjo de 3x3=9 caracteres (arranjo 3x3 orientado a linhas). Cada caractere pode ser \0 (caractere nulo) para indicar que a posição está vazia, ou a letra X, ou a letra O.

Na linha 20, o ponteiro m_font será usado para representar a fonte dos Xs e Os.

A classe tem duas funções:

  • checkEndCondition, que será usada no final de cada turno para verificar se m_board está em alguma condição de vitória ou empate;
  • restateGame, para limpar o tabuleiro e iniciar um novo jogo.

window.cpp

Aqui definiremos as funções membro da classe Window. Começaremos definindo Window::onCreate. Como Window::onCreate é chamada apenas uma vez quando a janela é criada, ela é ideal para fazermos as configurações iniciais da aplicação, como o carregamento da nova fonte para os Xs e Os, que tem tamanho maior que a fonte padrão. O resultado ficará assim:

#include "window.hpp"

void Window::onCreate() {
  // Load font with bigger size for the X's and O's
  auto const filename{abcg::Application::getAssetsPath() +
                      "Inconsolata-Medium.ttf"};
  m_font = ImGui::GetIO().Fonts->AddFontFromFileTTF(filename.c_str(), 72.0f);
  if (m_font == nullptr) {
    throw abcg::RuntimeError{"Cannot load font file"};
  }

  restartGame();
}

Na linha 5 criamos uma string com o caminho completo do arquivo Inconsolata-Medium.ttf. Para isso usamos a função abcg::Application::getAssetsPath() que retorna o caminho atual do subdiretório assets.

Na linha 7 chamamos as funções da ImGui para carregar a fonte com tamanho 72. Se ocorrer algum erro no carregamento, o ponteiro m_Font será nulo. Neste caso lançamos uma exceção (linha 9) que será capturada no bloco try...catch de main.

Na linha 12 chamamos a função restartGame para deixar o jogo pronto para uma nova partida. A propósito, vejamos como fica a implementação de restartGame:

void Window::restartGame() {
  m_board.fill('\0');
  m_gameState = GameState::Play;
}

A função simplesmente preenche o tabuleiro com caracteres nulos e define o estado do jogo como GameState::Play.

Vamos agora definir Window::onPaintUI. É nessa função que a interface será desenhada e a lógica de interação com os controles de UI implementada. Começaremos definindo a janela da ImGui:

void Window::onPaintUI() {
  // Get size of application's window
  auto const appWindowWidth{gsl::narrow<float>(getWindowSettings().width)};
  auto const appWindowHeight{gsl::narrow<float>(getWindowSettings().height)};

  // "Tic-Tac-Toe" window
  {
    ImGui::SetNextWindowSize(ImVec2(appWindowWidth, appWindowHeight));
    ImGui::SetNextWindowPos(ImVec2(0, 0));

    auto const flags{ImGuiWindowFlags_MenuBar | ImGuiWindowFlags_NoResize |
                     ImGuiWindowFlags_NoCollapse};
    ImGui::Begin("Tic-Tac-Toe", nullptr, flags);

    // TODO: Add Menu
    // TODO: Add static text showing current turn or win/draw messages
    // TODO: Add game board
    // TODO: Add "Restart game" button

    ImGui::End();
  }
}

Nas linhas 17 e 18 pegamos o tamanho atual da janela da aplicação através da função abcg::getWindowSettings. Essa função retorna uma referência a um objeto do tipo abcg::WindowSettings contendo as configurações da janela, incluindo sua largura (width) e altura (height). Os valores são inteiros e precisam ser convertidos para float para serem utilizados nas funções da ImGui.

O conteúdo da janela da ImGui é definido no escopo das linhas 21 a 35. Em particular, na linha 22 chamamos ImGui::SetNextWindowSize para informar que a janela que estamos prestes a criar terá o tamanho da janela da aplicação. De forma parecida, na linha 23 chamamos ImGui::SetNextWindowPos para informar que tal janela deverá ser posicionada na coordenada (0,0) da janela da aplicação (canto superior esquerdo).

Na linha 25 definimos uma máscara de bits com as propriedades da janela que será criada. A janela terá uma barra de menu (ImGuiWindowFlags_MenuBar), não poderá ser redimensionada (ImGuiWindowFlags_NoResize), e não poderá ser colapsada ao clicar na barra de título (ImGuiWindowFlags_NoCollapse).

Na linha 27 criamos de fato o controle de UI da janela. Ela é criada com as configurações definidas anteriormente. Todos os widgets criados entre essa linha de ImGui::Begin até a linha 34 de ImGui::End() serão colocados dentro dessa janela. Por enquanto a janela está vazia e só deixamos alguns comentários de tarefas a fazer:

    // TODO: Add Menu
    // TODO: Add static text showing current turn or win/draw messages
    // TODO: Add game board
    // TODO: Add "Restart game" button

Vamos fazer essas tarefas a seguir.

Adicionando o menu

Substitua a linha de comentário TODO: Add Menu pelo seguinte trecho de código:

    // Menu
    {
      bool restartSelected{};
      if (ImGui::BeginMenuBar()) {
        if (ImGui::BeginMenu("Game")) {
          ImGui::MenuItem("Restart", nullptr, &restartSelected);
          ImGui::EndMenu();
        }
        ImGui::EndMenuBar();
      }
      if (restartSelected) {
        restartGame();
      }
    }

Este código cria uma barra de menu com uma opção “Game”. Dentro desta opção há apenas um item de menu chamado “Restart”. Observe que o estado de “Restart” é armazenado na variável booleana restartSelected, inicializada com false na linha 31. Se o item de menu é selecionado, ImGui::MenuItem muda restartSelected para true e assim chamamos restartGame na linha 40 para reiniciar o estado do jogo.

Adicionando o texto do turno atual, de vitória e empate

Continuando com as tarefas por fazer, substitua a linha de comentário TODO: Add static text showing current turn or win/draw messages pelo seguinte código:

    // Static text showing current turn or win/draw messages
    {
      std::string text;
      switch (m_gameState) {
      case GameState::Play:
        text = fmt::format("{}'s turn", m_XsTurn ? 'X' : 'O');
        break;
      case GameState::Draw:
        text = "Draw!";
        break;
      case GameState::WinX:
        text = "X's player wins!";
        break;
      case GameState::WinO:
        text = "O's player wins!";
        break;
      }
      // Center text
      ImGui::SetCursorPosX(
          (appWindowWidth - ImGui::CalcTextSize(text.c_str()).x) / 2);
      ImGui::Text("%s", text.c_str());
      ImGui::Spacing();
    }

    ImGui::Spacing();

Na linha 46 definimos uma string que recebe um texto diferente dependendo do estado atual de m_gameState. Se o jogo está no modo Play, o texto será X's turn ou O's turn. Se o jogo está no modo Draw, WinX ou WinO, mensagens correspondentes de empate e vitória serão utilizadas.

Na linha 62, ImGui::SetCursorPosX define a posição horizontal da janela em que o texto começará a ser exibido, da esquerda para a direita. Para que o texto fique centralizado horizontalmente, sua posição inicial deve ser a metade da largura da janela (appWindowWidth / 2) menos a metade da largura do texto (calculada com ImGui::CalcTextSize).

O widget de texto é criado na linha 64. Em seguida, adicionamos um espaçamento vertical na linha 65, e outro na linha 68 para deixar um bom espaço entre o texto e o tabuleiro que será desenhado na próxima etapa.

Implementando o tabuleiro

O tabuleiro será mostrado como uma grade de 3x3 botões (no caso de m_N ser 3), sendo que cada botão terá como texto o caractere correspondente em m_board.

Substitua a linha de comentário TODO: Add game board pelo seguinte código:

    // Game board
    {
      auto const gridHeight{appWindowHeight - 22 - 60 - (m_N * 10) - 60};
      auto const buttonHeight{gridHeight / m_N};

      // Use custom font
      ImGui::PushFont(m_font);
      if (ImGui::BeginTable("Game board", m_N)) {
        for (auto i : iter::range(m_N)) {
          ImGui::TableNextRow();
          for (auto j : iter::range(m_N)) {
            ImGui::TableSetColumnIndex(j);
            auto const offset{i * m_N + j};

            // Get current character
            auto ch{m_board.at(offset)};

            // Replace null character with whitespace because the button label
            // cannot be an empty string
            if (ch == 0) {
              ch = ' ';
            }

            // Button text is ch followed by an ID in the format ##ij
            auto buttonText{fmt::format("{}##{}{}", ch, i, j)};
            if (ImGui::Button(buttonText.c_str(), ImVec2(-1, buttonHeight))) {
              if (m_gameState == GameState::Play && ch == ' ') {
                m_board.at(offset) = m_XsTurn ? 'X' : 'O';
                checkEndCondition();
                m_XsTurn = !m_XsTurn;
              }
            }
          }
          ImGui::Spacing();
        }
        ImGui::EndTable();
      }
      ImGui::PopFont();
    }

    ImGui::Spacing();

Neste código, primeiro começamos calculando dois valores de altura (linhas 72 e 73). gridHeight é a altura da área útil do tabuleiro. Ela é calculada a partir da altura da janela, subtraída da altura aproximada dos outros controles de UI (22 da barra de menu, 60+60 para a área acima e abaixo do tabuleiro) e do espaçamento entre os botões do tabuleiro (10). buttonHeight é a altura de cada botão.

Na linha 76, ImGui::PushFont faz com que a fonte m_font seja ativada no lugar da fonte padrão. Todo controle de UI definido entre essa linha até ImGui::PopFont (linha 107) usará essa fonte.

Na linha 77 criamos uma tabela “Game board”, composta de m_N colunas. O nome “Game board” não aparece na tela. Ele serve apenas como um identificador deste controle de UI.

Os laços das linha 78 e 79 iteram sobre as linhas e colunas do tabuleiro, respectivamente. Para cada nova linha chamamos ImGui::TableNextRow (linha 79), e para cada coluna chamamos ImGui::TableSetColumnIndex (linha 81) com o índice da coluna.

Na linha 85 lemos o caractere atual de m_board para a linha e coluna atual. Este caractere é a letra (X, O, ou vazio) que deve ser exibida como texto do botão. Entretanto, se for um caractere nulo, mudamos para um espaço (linhas 89 a 91) pois o comando ImGui::Button não aceita strings vazias.

Na linha 95 criamos o botão atual. Seu tamanho é ImVec2(-1, buttonHeight), o que significa que a largura será a máxima possível (definida por -1 ou outro valor negativo) e a altura será buttonHeight. O texto do botão (buttonText) é definido de forma um pouco mais complicada. O texto não é só o caractere ch. Se o caractere na posição (0,2) for um X, buttonText será X##02. Esse ##02 não é mostrado no botão. Ele é utilizado para indicar à ImGui que o identificador do botão é a string 02. Cada botão precisa ter um identificador único. A ImGui usa esses identificadores para definir quem está com o foco atual do teclado. Geralmente a ImGui usa o próprio texto do botão como identificador, mas como temos possivelmente vários botões com o mesmo texto (X, O ou espaço), precisamos recorrer à sintaxe ##id para definir identificadores únicos.

Se o botão é pressionado, ImGui::Button retorna true. Nesse caso, precisamos verificar se a posição correspondente de m_board pode ser de fato modificada. Para isso o jogo deve estar no modo Play e ch não pode ser X ou O (linha 96), isto é, cada posição do tabuleiro só pode ser preenchida uma vez.

Na linha 97 modificamos m_board para X ou O dependendo de quem está jogando o turno atual. Em seguida chamamos checkEndCondition para verificar se houve vitória ou empate (linha 98), e então alternamos o turno do jogador (linha 99).

Adicionando o botão de reinício

Abaixo do tabuleiro colocaremos um botão para reiniciar o jogo.

Substitua a linha de comentário TODO: Add "Restart game" button pelo seguinte código:

    // "Restart game" button
    {
      if (ImGui::Button("Restart game", ImVec2(-1, 50))) {
        restartGame();
      }
    }

O botão terá largura máxima (-1) e altura 50.

Note que, sempre que definimos um novo controle de UI, a ImGui cria o controle em uma nova linha (com exceção dos botões da tabela, criados entre ImGui::BeginTable e ImGui::EndTable). Fora de uma tabela, se quisermos que os controles não fiquem empilhados, podemos chamar ImGui::SameLine antes de criar o próximo controle. Assim ele será criado do lado direito do anterior.

Verificando a condição de vitória e empate

Para concluir a implementação de nosso Jogo da Velha, precisamos definir a função checkEndCondition. Essa função é chamada após cada jogada para verificar se m_board está em alguma condição de vitória ou empate:

  • Se alguma linha, coluna ou diagonal de m_board tiver somente X, então o jogador do X ganhou (devemos mudar o estado do jogo para GameState::WinX).
  • Se alguma linha, coluna ou diagonal de m_board tiver somente O, então o jogador do O ganhou (devemos ir ao estado GameState::WinO).
  • Se o tabuleiro não tiver mais nenhum caractere nulo, e nem o X nem o O ganharam, então “deu velha” (devemos ir ao estado GameState::Draw).

Vamos implementar a função aos poucos, começando com o código a seguir. Note que há vários comentários com tarefas por fazer (TODO):

void Window::checkEndCondition() {
  if (m_gameState != GameState::Play) {
    return;
  }

  // Lambda expression that checks if a string contains only Xs or Os. If so, it
  // changes the game state to WinX or WinO accordingly and returns true.
  // Otherwise, returns false.
  auto allXsOrOs{[&](std::string_view str) {
    if (str == std::string(m_N, 'X')) {
      m_gameState = GameState::WinX;
      return true;
    }
    if (str == std::string(m_N, 'O')) {
      m_gameState = GameState::WinO;
      return true;
    }
    return false;
  }};

  // TODO: Check rows
  // TODO: Check columns
  // TODO: Check main diagonal
  // TODO: Check inverse diagonal
  // TODO: Check draw
}

O tabuleiro só precisa ser verificado quando o jogo está no estado GameState::Play. A condição da linha 124 verifica isso.

Na linha 131 definimos uma expressão lambda que será usada várias vezes posteriormente para verificar as linhas, colunas e diagonais de m_board. Mais especificamente, a expressão lambda verifica se uma string str passada como parâmetro é composta apenas por 3 caracteres X ou 3 caracteres O (supondo que m_N é 3). Se sim, ela retorna true e seta o estado do jogo para WinX ou WinO de forma correspondente. Se não, ela só retorna false.

Vamos agora implementar o código que corresponde ao comentário TODO: Check rows, isto é, o código que verifica cada linha do tabuleiro:

  // Check rows
  for (auto const i : iter::range(m_N)) {
    std::string concatenation;
    for (auto const j : iter::range(m_N)) {
      concatenation += m_board.at(i * m_N + j);
    }
    if (allXsOrOs(concatenation)) {
      return;
    }
  }

Os laços aninhados iteram as linhas (i) e colunas (j) do tabuleiro. Para cada linha, uma string concatenation é preenchida com os caracteres daquela linha. allXsOrOs é então chamada para verificar se a string contém 3 X ou 3 O. Se sim, checkEndCondition retorna na linha 150 pois a condição final já foi encontrada. Caso contrário, a verificação deve continuar. O código dos outros TODOs é similar:

  // Check columns
  for (auto const j : iter::range(m_N)) {
    std::string concatenation;
    for (auto const i : iter::range(m_N)) {
      concatenation += m_board.at(i * m_N + j);
    }
    if (allXsOrOs(concatenation)) {
      return;
    }
  }

  // Check main diagonal
  {
    std::string concatenation;
    for (auto const i : iter::range(m_N)) {
      concatenation += m_board.at(i * m_N + i);
    }
    if (allXsOrOs(concatenation)) {
      return;
    }
  }

  // Check inverse diagonal
  {
    std::string concatenation;
    for (auto const i : iter::range(m_N)) {
      concatenation += m_board.at(i * m_N + (m_N - i - 1));
    }
    if (allXsOrOs(concatenation)) {
      return;
    }
  }

  // Check draw
  if (std::find(m_board.begin(), m_board.end(), '\0') == m_board.end()) {
    m_gameState = GameState::Draw;
  }

A verificação do empate é feita na condicional da linha 188. Observe que ela só será executada se as condições de vitória anteriores não tiverem sido satisfeitas. Então, se nesse momento não tiver nenhum caractere nulo em m_board, significa que o tabuleiro está todo preenchido com X e O mas ninguém ganhou, isto é, “deu velha”.

Laços baseados em intervalos

Observe que usamos laços for baseados em intervalos (range-based for loops) juntos com a função iter::range da biblioteca CPPItertools.

Use laços baseados em intervalos sempre que possível. Eles são mais fáceis de ler e mais seguros pois evitam bugs comuns como trocar < por outro comparador (>, ou <=) ou incrementar a variável errada.

Por exemplo, para iterar com um índice i de 0 a 9, ao invés de usar o for tradicional

for (int i = 0; i < 10; ++i) {
  // i = 0, 1, ..., 9
}

prefira fazer assim:

for (auto i : iter::range(10)) {
  // i = 0, 1, ..., 9
}

iter::range funciona da mesma forma que a função range do Python.

De forma semelhante, para iterar um arranjo a e imprimir seu conteúdo, prefira fazer assim

std::array a{"foo", "bar", "baz"};
  
for (auto const &str : a) {
  fmt::print("{}", str);
}

ao invés de:

std::array a{"foo", "bar", "baz"};

for (std::size_t i = 0; i < a.size(); ++i) {
  fmt::print("{}", a[i]);
}

O projeto completo do Jogo da Velha pode ser baixado deste link.