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
eImGui::EndTable
para fazer tabelas;ImGui::Spacing
para adicionar espaçamentos verticais;ImGui::PushFont
eImGui::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
ouO
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
Nosso projeto será chamado
tictactoe
. Emabcg/examples
, crie o subdiretórioabcg/examples/tictactoe
.Abra o arquivo
abcg/examples/CMakeLists.txt
e acrescente a linhaadd_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)
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})
Em
abcg/examples/tictactoe
, crie o subdiretórioassets
. Nas aplicações usando a ABCg, o subdiretórioassets
é utilizado para armazenar arquivos de recursos utilizados em tempo de execução (arquivos de fontes, imagens, sons, etc). No nosso caso, colocaremos emassets
o arquivo de fonte TrueTypeInconsolata-Medium.ttf
que será utilizado para o texto dosX
s eO
s. O arquivo pode ser baixado, ou simplesmente copiado deabcg/abcg/assets
(essa também é a fonte padrão da ABCg).
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
.
- Em
abcg/examples/tictactoe
, crie os arquivosmain.cpp
,window.cpp
ewindow.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 umX
ouO
;GameState::Draw
é quando o jogo acabou e “deu velha”;GameState::WinX
é quando o jogo acabou eX
ganhou;GameState::WinO
é quando o jogo acabou eO
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 X
s e O
s.
A classe tem duas funções:
checkEndCondition
, que será usada no final de cada turno para verificar sem_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 X
s e O
s, 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
:
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 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:
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 somenteX
, então o jogador doX
ganhou (devemos mudar o estado do jogo paraGameState::WinX
). - Se alguma linha, coluna ou diagonal de
m_board
tiver somenteO
, então o jogador doO
ganhou (devemos ir ao estadoGameState::WinO
). - Se o tabuleiro não tiver mais nenhum caractere nulo, e nem o
X
nem oO
ganharam, então “deu velha” (devemos ir ao estadoGameState::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 TODO
s é 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”.
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) {
::print("{}", str);
fmt}
ao invés de:
std::array a{"foo", "bar", "baz"};
for (std::size_t i = 0; i < a.size(); ++i) {
::print("{}", a[i]);
fmt}
O projeto completo do Jogo da Velha pode ser baixado deste link.