65.9K
CodeProject 正在变化。 阅读更多。
Home

C++ 很有趣:编写一个井字游戏

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.97/5 (36投票s)

2013年11月4日

CPOL

20分钟阅读

viewsIcon

244390

downloadIcon

9870

本文展示了如何使用 Win32 和 WinRT 等不同技术在 C++ 中创建井字棋游戏,以证明用 C++ 编写代码与使用其他编程语言一样有趣且高效。

引言

本“C++ 乐趣”系列旨在表明,用 C++ 编写代码可以像使用其他主流语言一样高效且有趣。在本系列的第二部分中,我将带您从头开始创建一个 C++ 井字棋游戏。本文以及整个系列都面向希望学习 C++ 或对该语言功能感到好奇的开发人员。

许多年轻人想学习编程来制作游戏。C++ 是编写游戏最常用的语言,尽管在制作下一个《愤怒的小鸟》之前需要积累大量的编程经验。井字棋游戏是一个不错的起点,事实上,很多年前,这是我开始学习 C++ 后写的第一款游戏。我希望本文能帮助初学者和不熟悉 C++ 的经验丰富的开发人员。

我将使用 Visual Studio 2012 来编写本文的源代码。

游戏

如果您从未玩过井字棋,或者不熟悉这个游戏,这里是来自维基百科的描述。

井字棋(也称为“交叉圈”或“圈叉”)是一款两人纸笔游戏,玩家 X 和 O 轮流在一个 3x3 的网格中标记格子。成功将三个相同的标记放在水平、垂直或对角线上线的玩家赢得游戏。

游戏将在计算机和人类对手之间进行。两人都可以先开始。

创建游戏需要实现两部分:游戏逻辑和游戏用户界面。在 Windows 上创建 UI 有多种可能性,包括 Win32 API、MFC、ATL、GDI+、DirectX 等。在本文中,我将展示如何使用相同的游戏逻辑实现来使用各种技术构建应用程序。我们将创建两个应用程序,一个使用 Win32 API,另一个使用 C++/CX 来构建 Windows Runtime。

游戏逻辑

如果玩家遵循几个简单的规则来放置下一个标记,他们就可以玩出完美的游戏(即获胜或平局)。规则在维基百科上进行了描述,您也可以在那里找到先手玩家的最佳策略。

先手和后手玩家的最佳策略可以在xkcd 的图画中找到。尽管它存在一些错误(在几种情况下错失了获胜的走法,并且至少有一个地方缺少了一个 X 标记),但我将使用这个版本作为走法策略(并修复了我能找到的错误)。请注意,这意味着计算机将始终玩出完美的游戏。如果您实现这样的游戏,您可能还希望让用户获胜,在这种情况下,您需要一种不同的方法。但出于本文的目的,这应该足够了。

首先出现的问题是,我们可以使用什么数据结构来在 C++ 程序中模拟这个图?可能有不同的选择,例如树、图、数组,甚至位域(如果您对内存消耗非常挑剔)。网格有 9 个单元格,在我看来,最简单的选择是使用一个包含 9 个整数的数组,每个单元格一个:0 代表空单元格,1 代表标记为 X 的单元格,2 代表标记为 O 的单元格。让我们看看下面的图,并了解如何对其进行编码。

图可以这样解读

  • 在单元格 (0,0) 中放置一个 X。网格可以编码为:1, 0, 0, 0, 0, 0, 0, 0, 0
  • 如果对手在单元格 (0,1) 中放置了一个 O,那么在单元格 (1,1) 中放置一个 X。网格编码现在是:1, 2, 0, 0, 1, 0, 0, 0, 0
  • 如果对手在单元格 (0,2) 中放置了一个 O,那么在单元格 (2,2) 中放置一个 X。网格编码现在是:1, 2, 2, 0, 1, 0, 0, 0, 1。这代表了一次获胜的走法。
  • ...
  • 如果对手在单元格 (2,2) 中放置了一个 O,那么在单元格 (2,0) 中放置一个 X。网格编码现在是:1, 2, 0, 0, 1, 0, 1, 0, 2。此时,无论对手做出什么走法,X 都将赢得比赛。
  • 如果对手在单元格 (0,2) 中放置了一个 O,那么在单元格 (1,0) 中放置一个 X。网格编码现在是:1, 2, 2, 1, 1, 0, 1, 0, 2。这代表了一次获胜的走法。
  • ...

有了这个想法,我们就可以继续在程序中对其进行编码。我们将使用 `std::array` 来表示 9 个单元格的棋盘。这是一个固定大小的容器,其大小在编译时已知,并将元素存储在连续的内存区域中。为了简化重复使用相同的数组类型,我将为此定义一个别名。

#include <array>

typedef std::array<char, 9> tictactoe_status;

上述最佳策略表示为一系列(另一个数组)这样的数组。

tictactoe_status const strategy_x[] = 
{
   {1,0,0,0,0,0,0,0,0},
   {1,2,0,0,1,0,0,0,0},
   {1,2,2,0,1,0,0,0,1},
   {1,2,0,2,1,0,0,0,1},
   // ...
};

tictactoe_status const strategy_o[] = 
{
   {2,0,0,0,1,0,0,0,0},
   {2,2,1,0,1,0,0,0,0},
   {2,2,1,2,1,0,1,0,0},
   {2,2,1,0,1,2,1,0,0},
   // ...
};

`strategy_x` 是先手的最佳策略,`strategy_o` 是后手的最佳策略。如果您查看本文提供的源代码,您会发现这两个数组的实际定义与我之前展示的有所不同。

tictactoe_status const strategy_x[] = 
{
#include "strategy_x.h"
};

tictactoe_status const strategy_o[] = 
{
#include "strategy_o.h"
};

这是一个小技巧,我认为,它允许我们将数组中实际的、较长的内容移到一个单独的文件中(这些文件的实际扩展名并不重要,可以是任何东西,不只是 C++ 头文件),从而保持源文件和定义的简洁清晰。`strategy_x.h` 和 `strategy_o.h` 文件的内容在编译的预处理阶段被引入到源文件中,就像普通的头文件一样。这是 `strategy_x.h` 文件的一个片段。

// http://imgs.xkcd.com/comics/tic_tac_toe_large.png
// similar version on http://upload.wikimedia.org/wikipedia/commons/d/de/Tictactoe-X.svg
// 1 = X, 2 = O, 0 = unoccupied

1,0,0,0,0,0,0,0,0,

1,2,0,0,1,0,0,0,0,
1,2,2,0,1,0,0,0,1,
1,2,0,2,1,0,0,0,1,
1,2,0,0,1,2,0,0,1,

您应该注意到,如果您使用符合 C++11 标准的编译器,可以使用 `std::vector` 代替 C 风格的数组。Visual Studio 2012 不支持这一点,但 Visual Studio 2013 支持。

std::vector<tictactoe_status> strategy_o = 
{
   {2, 0, 0, 0, 1, 0, 0, 0, 0},
   {2, 2, 1, 0, 1, 0, 0, 0, 0},
   {2, 2, 1, 2, 1, 0, 1, 0, 0},
   {2, 2, 1, 0, 1, 2, 1, 0, 0},
   {2, 2, 1, 1, 1, 0, 2, 0, 0},
};

为了定义数组中的这些数字代表哪个玩家,我定义了一个名为 `tictactoe_player` 的枚举。

enum class tictactoe_player : char
{
   none = 0,
   computer = 1,
   user = 2,
};

游戏逻辑将实现为一个名为 `tictactoe_game` 的类。至少,该类应该具有以下状态

  • 一个布尔标志,指示游戏是否已开始,由 `started` 表示
  • 游戏的当前状态(棋盘上的标记),由 `status` 表示
  • 基于游戏当前状态未来可能进行的走法集合,由 `strategy` 表示。
class tictactoe_game
{
   bool started;
   tictactoe_status status;
   std::set<tictactoe_status> strategy;
   
   // ...
};

在游戏过程中,我们需要知道游戏是已开始、已结束,如果已结束,是任何一方获胜还是游戏以平局告终。`tictactoe_game` 提供了三个方法来实现这一点

  • `is_started` 指示游戏是否已开始
  • `is_victory` 检查指定玩家是否已获胜
  • `is_finished` 检查游戏是否已结束。当一方玩家获胜或棋盘已满且玩家无法进行任何额外走法时,游戏即告结束。
bool is_started() const {return started;}
bool is_victory(tictactoe_player const player) const {return is_winning(status, player);}
bool is_finished() const 
{
  return is_full(status) || 
         is_victory(tictactoe_player::user) ||
         is_victory(tictactoe_player::computer);
}

`is_victory` 和 `is_finished` 的实现实际上依赖于两个 `private` 方法:`is_full`,指示棋盘是否已满且无法进行任何走法;以及 `is_winning`,指示在给定棋盘上,给定玩家是否已获胜。它们的实现应该很容易理解。`is_full` 计算棋盘(数组)中空单元格(数组值为 `0`)的数量,如果不存在这样的单元格则返回 `true`。`is_winning` 检查棋盘的行、列和两个对角线,以查看给定玩家是否已连成一条获胜线。

bool is_winning(tictactoe_status const & status, tictactoe_player const player) const
{
   auto mark = static_cast<char>(player);
   return 
      (status[0] == mark && status[1] == mark && status[2] == mark) ||
      (status[3] == mark && status[4] == mark && status[5] == mark) ||
      (status[6] == mark && status[7] == mark && status[8] == mark) ||
      (status[0] == mark && status[4] == mark && status[8] == mark) ||
      (status[2] == mark && status[4] == mark && status[6] == mark) ||
      (status[0] == mark && status[3] == mark && status[6] == mark) ||
      (status[1] == mark && status[4] == mark && status[7] == mark) ||
      (status[2] == mark && status[5] == mark && status[8] == mark);
}

bool is_full(tictactoe_status const & status) const 
{
   return 0 == std::count_if(std::begin(status), std::end(status), 
               [](int const mark){return mark == 0;});
}

当玩家赢得比赛时,我们希望在获胜的列、行或对角线上画一条线。因此,我们需要知道那是哪条获胜线。`get_winning_line` 方法返回一对 `tictactoe_cell`,指示线的两个端点。其实现与 `is_winning` 非常相似:它检查行、列和对角线,如果其中一条是获胜线,则返回其两个端点(坐标)。它看起来可能有点冗长,但我认为这样检查线条比运行三轮循环来检查行、列和对角线要简单。

struct tictactoe_cell
{
   int row;
   int col;

   tictactoe_cell(int r = INT_MAX, int c = INT_MAX):row(r), col(c)
   {}

   bool is_valid() const {return row != INT_MAX && col != INT_MAX;}
};

std::pair<tictactoe_cell, tictactoe_cell> const get_winning_line() const
{
   auto mark = static_cast<char>(tictactoe_player::none);
   if(is_victory(tictactoe_player::computer))
      mark = static_cast<char>(tictactoe_player::computer);
   else if(is_victory(tictactoe_player::user))
      mark = static_cast<char>(tictactoe_player::user);

   if(mark != 0)
   {
      if(status[0] == mark && status[1] == mark && status[2] == mark) 
         return std::make_pair(tictactoe_cell(0,0), tictactoe_cell(0,2));
      if(status[3] == mark && status[4] == mark && status[5] == mark)
         return std::make_pair(tictactoe_cell(1,0), tictactoe_cell(1,2));
      if(status[6] == mark && status[7] == mark && status[8] == mark)
         return std::make_pair(tictactoe_cell(2,0), tictactoe_cell(2,2));
      if(status[0] == mark && status[4] == mark && status[8] == mark)
         return std::make_pair(tictactoe_cell(0,0), tictactoe_cell(2,2));
      if(status[2] == mark && status[4] == mark && status[6] == mark)
         return std::make_pair(tictactoe_cell(0,2), tictactoe_cell(2,0));
      if(status[0] == mark && status[3] == mark && status[6] == mark)
         return std::make_pair(tictactoe_cell(0,0), tictactoe_cell(2,0));
      if(status[1] == mark && status[4] == mark && status[7] == mark)
         return std::make_pair(tictactoe_cell(0,1), tictactoe_cell(2,1));
      if(status[2] == mark && status[5] == mark && status[8] == mark)
         return std::make_pair(tictactoe_cell(0,2), tictactoe_cell(2,2));
   }

   return std::make_pair(tictactoe_cell(), tictactoe_cell());
}

此时唯一剩下要做的就是开始新游戏和走棋(包括计算机和用户)。

要开始新游戏,我们需要知道哪个玩家先走,以便我们可以选择合适的策略(在两个可用策略中)。我们还必须重置表示游戏棋盘的数组。`start()` 方法初始化一个新游戏。可能走法的集合会从 `strategy_x` 或 `strategy_o` 数组重新初始化。请注意,在下面的代码中,`strategy` 是一个 `std::set`,而 `strategy_x` 和 `strategy_o` 是包含重复条目的数组,因为井字棋图中的某些位置是重复的。set 是一个包含唯一值的容器,它只保留唯一的可能位置(例如,`strategy_o` 数组大约一半由重复项组成)。使用 `std::copy` 从 `` 中将数组的内容复制到 set 中,并使用 `assign()` 方法为 `std::array` 的所有元素设置一个值(在本例中为 0)。

void start(tictactoe_player const player)
{
   strategy.clear();
   if(player == tictactoe_player::computer)
      std::copy(std::begin(strategy_x), std::end(strategy_x), 
                std::inserter(strategy, std::begin(strategy)));
   else if(player == tictactoe_player::user)
      std::copy(std::begin(strategy_o), std::end(strategy_o), 
                std::inserter(strategy, std::begin(strategy)));
                
   status.assign(0);
   
   started = true;
}

要为人类玩家走棋,我们只需要确保所选单元格是空的,如果是,则用相应的标记填充它。`move()` 方法接受单元格的坐标、玩家的标记,并返回 `true` 表示走法有效,否则返回 `false`。

bool move(tictactoe_cell const cell, tictactoe_player const player)
{
   if(status[cell.row*3 + cell.col] == 0)
   {
      status[cell.row*3 + cell.col] = static_cast<char>(player);
      
      if(is_victory(player))
      {
         started = false;
      }
      
      return true;
   }

   return false;
}

为计算机走棋需要更多工作,因为我们必须找到计算机应该进行的下一个最佳走法。`move()` 方法的重载会查找下一组可能的走法(策略),然后从这组可能的走法中选择最佳走法。走棋后,它会检查该走法是否为计算机赢得了比赛,如果是,则将游戏标记为已结束。该方法返回计算机放置其走法的棋盘位置。

tictactoe_cell move(tictactoe_player const player)
{
   tictactoe_cell cell;

   strategy = lookup_strategy();

   if(!strategy.empty())
   {
      auto newstatus = lookup_move();

      for(int i = 0; i < 9; ++i)
      {
         if(status[i] == 0 && newstatus[i]==static_cast<char>(player))
         {
            cell.row = i/3;
            cell.col = i%3;
            break;
         }
      }

      status = newstatus;

      if(is_victory(player))
      {
         started = false;
      }
   }

   return cell;
}

`lookup_strategy()` 方法会遍历当前可能的走法,以查找从当前走法开始有哪些走法是可能的。它利用了空单元格由 0 表示,而任何已填充的单元格要么是 1 要么是 2,这两个值都大于 0。单元格的值只能从 0 变为 1 或 2。单元格永远不会从 1 变为 2,或从 2 变为 1。

游戏开始时,棋盘表示为 `0,0,0,0,0,0,0,0,0`,并且从该位置开始任何走法都是可能的。这就是为什么在 `start()` 方法中,我们复制了所有可能的走法。一旦玩家走棋,可能的走法集合就会减少。例如,玩家在第一个单元格走棋。棋盘然后表示为 `1,0,0,0,0,0,0,0,0`。此时,第一个单元格中带有 `0` 或 `2` 的任何走法都不再可能,应该被过滤掉。

std::set<tictactoe_status> tictactoe_game::lookup_strategy() const
{
   std::set<tictactoe_status> nextsubstrategy;

   for(auto const & s : strategy)
   {
      bool match = true;
      for(int i = 0; i < 9 && match; ++i)
      {
         if(s[i] < status[i])
            match = false;
      }

      if(match)
      {
         nextsubstrategy.insert(s);
      }
   }

   return nextsubstrategy;
}

选择下一个走法时,我们必须确保选择的走法仅与当前位置在单个标记上有区别。如果当前位置是 `1,2,0,0,0,0,0,0,0`,而我们必须为玩家 1 走棋,我们只能选择在数组的最后 7 个元素中有一个 1 的走法:`1,2,1,0,0,0,0,0,0` 或 `1,2,0,1,0,0,0,0,0`...或 `1,2,0,0,0,0,0,0,1`。然而,由于存在不止一种此类走法,我们应该选择最佳走法,而最佳走法总是赢得比赛的走法。因此,我们必须检查所有走法是否为获胜走法。如果没有可用的获胜走法,那么我们可以随便选一个。

tictactoe_status tictactoe_game::lookup_move() const
{
   tictactoe_status newbest = {0};
   for(auto const & s : strategy)
   {
      int diff = 0;
      for(int i = 0; i < 9; ++i)
      {
         if(s[i] > status[i])
            diff++;
      }

      if(diff == 1)
      {
         newbest = s;
         if(is_winning(newbest, tictactoe_player::computer))
         {
            break;
         }
      }
   }

   assert(newbest != empty_board);

   return newbest;
}

至此,我们已经为游戏逻辑做好了所有准备。有关更多详细信息,您可以阅读 `game.h` 和 `game.cpp` 文件中的代码。

使用 Win32 API 实现游戏

我将创建的第一个应用程序将使用 Win32 API 来构建用户界面。如果您不熟悉 Win32 编程,有大量的资源可以帮助您学习更多。我只会介绍一些基本方面,以便理解如何构建最终应用程序。此外,我不会展示和解释这个部分的所有代码,但由于代码可供下载,您可以浏览和阅读它。

至少,Win32 应用程序需要以下内容

  • 一个入口点,按照惯例是 WinMain,而不是 `main`。它接受参数,例如当前应用程序实例的句柄、命令行和指示如何显示窗口的标志。
  • 一个窗口类,它代表创建窗口的模板。窗口类包含系统使用的一组属性,例如类名、类样式(不同于窗口样式)、图标、菜单、背景画笔、指向窗口过程的指针等。窗口类是进程特定的,必须在创建窗口之前注册到系统中。要注册窗口类,请使用 RegisterClassEx
  • 一个主窗口,根据窗口类创建。可以使用 `CreateWindowEx` 函数创建窗口。
  • 一个窗口过程,它是一个函数,用于处理基于窗口类创建的所有窗口的消息。窗口过程与窗口类关联,而不是与窗口关联。
  • 一个消息循环。窗口可以通过两种方式接收消息:使用 SendMessage,它直接调用窗口过程并等待窗口过程处理完消息后才返回;或者使用 PostMessage(或 PostThreadMessage),它将消息发布到创建窗口的线程的消息队列并返回,而不等待线程处理消息。因此,线程必须运行一个循环,从消息队列中获取消息并将它们分派给窗口过程。

您可以在 MSDN 中找到一个简单的 Win32 应用程序示例,该示例注册窗口类、创建窗口并运行消息循环。从概念上讲,Win32 应用程序看起来像这样

int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow)
{
   WNDCLASS wc; 
   // set the window class attributes
   // including pointer to a window procedure
   
   if (!::RegisterClass(&wc))
      return FALSE;
      
   HWND wnd = ::CreateWindowEx(...);
   if(!wnd)
      return FALSE;
      
   ::ShowWindow(wnd, nCmdShow); 
   
   MSG msg;
   while(::GetMessage(&msg, nullptr, 0, 0))
   {
      ::TranslateMessage(&msg);
      ::DispatchMessage(&msg);
   }

   return msg.wParam;   
}

这还不够,我们仍然需要一个窗口过程来处理发送到窗口的消息,例如绘制命令、销毁消息、菜单命令以及处理其他任何必需的内容。窗口过程可能看起来像这样

LRESULT WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam)
{
   switch(message)
   {
   case WM_PAINT:
      {
         PAINTSTRUCT ps;
         HDC dc = ::BeginPaint(hWnd, &ps);
         // paint
         ::EndPaint(hWnd, &ps);
      }
      break;

   case WM_DESTROY:
      ::PostQuitMessage(0);
      return 0;

   case WM_COMMAND:
      {
         ...
      }
      break;
   }

   return ::DefWindowProc(hWnd, message, wParam, lParam);
}

我喜欢编写更多面向对象的代码,而不是过程式的代码,所以我将几个类包装了窗口类、窗口和设备上下文。您可以在附件源代码的 `framework.h` 和 `framework.cpp` 文件中找到这些类(它们非常小)的实现。

  • `WindowClass` 是一个 RAII 风格的窗口类包装器。在构造函数中,它填充一个 WNDCLASSEX 结构并调用 RegisterClassEx。在析构函数中,它通过调用 UnregisterClass 来注销窗口类。
  • `Window` 是 `HWND` 的一个薄包装器,它公开诸如 `Create`、`ShowWindow` 和 `Invalidate` 等方法(它们的名字应该能说明它们的作用)。它还有几个虚拟成员,代表从窗口过程中调用的消息处理程序(`OnPaint`、`OnMenuItemClicked`、`OnLeftButtonDown`)。此窗口旨在通过派生来提供特定的实现。
  • `DeviceContext` 是一个设备上下文(`HDC`)的 RAII 风格的包装类。在构造函数中,它调用 BeginPaint,在析构函数中,它调用 EndPaint

游戏主窗口是 `TicTacToeWindow`,它派生自 `Window` 类。它重写了用于处理消息的虚拟方法。该类的声明如下所示

class TicTacToeWindow : public Window
{
   HANDLE hBmp0;
   HANDLE hBmpX;
   BITMAP bmp0;
   BITMAP bmpX;

   tictactoe_game game;

   void DrawBackground(HDC dc, RECT rc);
   void DrawGrid(HDC dc, RECT rc);
   void DrawMarks(HDC dc, RECT rc);
   void DrawCut(HDC dc, RECT rc);

   virtual void OnPaint(DeviceContext* dc) override;
   virtual void OnLeftButtonUp(int x, int y, WPARAM params) override;
   virtual void OnMenuItemClicked(int menuId) override;

public:
   TicTacToeWindow();
   virtual ~TicTacToeWindow() override;
};

`OnPaint()` 方法处理窗口的绘制。它必须绘制窗口背景、网格线、已填充单元格中的标记(如果有),并且如果游戏已结束且某方获胜,则在获胜的行、列或对角线上画一条红线。为避免闪烁,我们使用双缓冲技术:创建一个与通过调用 `BeginPaint` 为窗口准备的设备上下文兼容的内存设备上下文,一个与内存设备上下文兼容的内存位图,在此位图上绘制,然后将内存设备上下文的内容复制到窗口设备上下文。

void TicTacToeWindow::OnPaint(DeviceContext* dc)
{
   RECT rcClient;
   ::GetClientRect(hWnd, &rcClient);

   auto memdc = ::CreateCompatibleDC(*dc);
   auto membmp = ::CreateCompatibleBitmap
                   (*dc, rcClient.right - rcClient.left, rcClient.bottom-rcClient.top);
   auto bmpOld = ::SelectObject(memdc, membmp);
   
   DrawBackground(memdc, rcClient);

   DrawGrid(memdc, rcClient);

   DrawMarks(memdc, rcClient);

   DrawCut(memdc, rcClient);

   ::BitBlt(*dc, 
      rcClient.left, 
      rcClient.top, 
      rcClient.right - rcClient.left, 
      rcClient.bottom-rcClient.top,
      memdc, 
      0, 
      0, 
      SRCCOPY);

   ::SelectObject(memdc, bmpOld);
   ::DeleteObject(membmp);
   ::DeleteDC(memdc);
}

我将不在此列出 `DrawBackground`、`DrawGrid` 和 `DrawMarks` 函数的内容。它们并不复杂,您可以阅读源代码。`DrawMarks` 函数使用两个位图 `ttt0.bmp` 和 `tttx.bmp` 来绘制网格中的标记。

我将只展示如何绘制一条红线来覆盖获胜的行、列或对角线。首先,我们必须检查游戏是否已结束,如果已结束,则检索获胜线。如果两个端点都有效,则计算两个单元格的中心,创建并选择一个画笔(实线,15 像素宽的红线),然后在两个单元格的中间绘制一条线。

void TicTacToeWindow::DrawCut(HDC dc, RECT rc)
{
   if(game.is_finished())
   {
      auto streak = game.get_winning_line();

      if(streak.first.is_valid() && streak.second.is_valid())
      {
         int cellw = (rc.right - rc.left) / 3;
         int cellh = (rc.bottom - rc.top) / 3;

         auto penLine = ::CreatePen(PS_SOLID, 15, COLORREF(0x2222ff));
         auto penOld = ::SelectObject(dc, static_cast<HPEN>(penLine));

         ::MoveToEx(
            dc, 
            rc.left + streak.first.col * cellw + cellw/2, 
            rc.top + streak.first.row * cellh + cellh/2,
            nullptr);

         ::LineTo(dc,
            rc.left + streak.second.col * cellw + cellw/2,
            rc.top + streak.second.row * cellh + cellh/2);

         ::SelectObject(dc, penOld);
      }
   }
}

主窗口有一个菜单,包含三个项:`ID_GAME_STARTUSER`,它启动用户先走的比赛;`ID_GAME_STARTCOMPUTER`,它启动计算机先走的比赛;以及 `ID_GAME_EXIT`,它关闭应用程序。当用户单击两个开始命令之一时,我们必须启动比赛。如果计算机先走,那么我们应该让它走棋,然后在两种情况下都重绘窗口。

void TicTacToeWindow::OnMenuItemClicked(int menuId)
{
   switch(menuId)
   {
   case ID_GAME_EXIT:
      ::PostMessage(hWnd, WM_CLOSE, 0, 0);
      break;

   case ID_GAME_STARTUSER:
      game.start(tictactoe_player::user);
      Invalidate(FALSE);
      break;

   case ID_GAME_STARTCOMPUTER:
      game.start(tictactoe_player::computer);
      game.move(tictactoe_player::computer);
      Invalidate(FALSE);
      break;
   }
}

在窗口中唯一需要处理的是处理用户在窗口上的鼠标单击。当用户单击窗口客户区域中的某个点时,我们应该检查它是哪个网格单元格,如果它是空的,则用用户的标记填充它。然后,如果游戏尚未结束,就让计算机走棋。

void TicTacToeWindow::OnLeftButtonUp(int x, int y, WPARAM params)
{
   if(game.is_started() && !game.is_finished())
   {
      RECT rcClient;
      ::GetClientRect(hWnd, &rcClient);

      int cellw = (rcClient.right - rcClient.left) / 3;
      int cellh = (rcClient.bottom - rcClient.top) / 3;

      int col = x / cellw;
      int row = y / cellh;

      if(game.move(tictactoe_cell(row, col), tictactoe_player::user))
      {
         if(!game.is_finished())
            game.move(tictactoe_player::computer);

         Invalidate(FALSE);
      }
   }
}

最后,我们需要实现 `WinMain` 函数,这是应用程序的入口点。下面的代码与本节开头所示的代码非常相似,区别在于它使用了我的包装类来处理窗口类和窗口。

int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow)
{
   WindowClass wndcls(hInstance, L"TicTacToeWindowClass", 
                      MAKEINTRESOURCE(IDR_MENU_TTT), CallWinProc);   

   TicTacToeWindow wnd;
   if(wnd.Create(
      wndcls.Name(), 
      L"Fun C++: TicTacToe", 
      WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX, 
      CW_USEDEFAULT, 
      CW_USEDEFAULT, 
      300, 
      300, 
      hInstance))
   {
      wnd.ShowWindow(nCmdShow);

      MSG msg;
      while(::GetMessage(&msg, nullptr, 0, 0))
      {
         ::TranslateMessage(&msg);
         ::DispatchMessage(&msg);
      }

      return msg.wParam;
   }

   return 0;
}

如果您不熟悉 Win32 API 编程,可能会觉得这有点繁琐,尽管在我看来,我编写的代码量相对较少且简单。但是,您必须明确处理对象初始化、窗口创建、消息处理等所有事项。希望您会觉得下一节更有吸引力。

Windows Runtime 的游戏应用程序

Windows Runtime 是 Windows 8 中引入的一个新的 Windows 运行时引擎。它与 Win32 并存,并具有基于 COM 的 API。为 Windows Runtime 构建的应用程序被称为“Windows 应用商店”应用程序,这是一种糟糕的说法。它们在 Windows Runtime 中运行,而不是在 Windows 应用商店中运行,但微软营销部门的员工可能缺乏创意。Windows Runtime 应用程序和组件可以用 C++ 编写,使用 Windows Runtime C++ Template Library (WTL) 或 C++ Component Extensions (C++/CX)。在本文中,我将使用 XAML 和 C++/CX 来构建一个功能与上一节创建的桌面应用程序类似的应用程序。

当您创建一个 Windows 应用商店空白 XAML 应用程序时,向导创建的项目实际上并非为空,而是包含构建和运行 Windows 应用商店应用程序的所有文件和设置。空白的是应用程序的主页面。

出于本文的目的,我们唯一需要关心的是主页面。XAML 代码在 `MainPage.xaml` 文件中,代码隐藏在 `MainPage.xaml.h` 和 `MainPage.xaml.cpp` 中。我想构建的简单应用程序看起来如下面的图像。

页面的 XAML 可能看起来像这样(在一个真实的应用程序中,您可能希望使用应用栏来执行诸如开始新游戏之类的操作,而不是在主页面上放置按钮,但为了简单起见,我将它们放在页面上)

<Page
    x:Class="TicTacToeWinRT.MainPage"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="using:TicTacToeWinRT"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    mc:Ignorable="d">   
   
   <Grid Background="{StaticResource ApplicationPageBackgroundThemeBrush}">
      <Grid.RowDefinitions>
         <RowDefinition Height="Auto" />
         <RowDefinition Height="Auto" />
         <RowDefinition Height="Auto" />
         <RowDefinition Height="Auto" />
      </Grid.RowDefinitions>
      
      <TextBlock Grid.Row="0" Text="Fun C++: Tic Tac Toe" 
                 Foreground="White" FontSize="42" FontFamily="Segoe UI"
                 Margin="10"
                 HorizontalAlignment="Center" VerticalAlignment="Center"
                 />

      <TextBlock Grid.Row="1" Text="Computer wins!"
                 Name="txtStatus"
                 Foreground="LightGoldenrodYellow" 
                 FontSize="42" FontFamily="Segoe UI"
                 Margin="10"
                 HorizontalAlignment="Center" VerticalAlignment="Center" />
      
      <Grid Margin="50" Width="400" Height="400" Background="White"
            Name="board"
            PointerReleased="board_PointerReleased"
            Grid.Row="2">
         <Grid.ColumnDefinitions>
            <ColumnDefinition Width="1*" />
            <ColumnDefinition Width="1*" />
            <ColumnDefinition Width="1*" />
         </Grid.ColumnDefinitions>
         <Grid.RowDefinitions>
            <RowDefinition Height="1*" />
            <RowDefinition Height="1*" />
            <RowDefinition Height="1*" />
         </Grid.RowDefinitions>

         <!-- Horizontal Lines -->
         <Rectangle Grid.Row="0" Grid.ColumnSpan="3" Height="1" VerticalAlignment="Bottom" Fill="Black"/>
         <Rectangle Grid.Row="1" Grid.ColumnSpan="3" Height="1" VerticalAlignment="Bottom" Fill="Black"/>
         <Rectangle Grid.Row="2" Grid.ColumnSpan="3" Height="1" VerticalAlignment="Bottom" Fill="Black"/>
         <!-- Vertical Lines -->
         <Rectangle Grid.Column="0" Grid.RowSpan="3" Width="1" HorizontalAlignment="Right" Fill="Black"/>
         <Rectangle Grid.Column="1" Grid.RowSpan="3" Width="1" HorizontalAlignment="Right" Fill="Black"/>
         <Rectangle Grid.Column="2" Grid.RowSpan="3" Width="1" HorizontalAlignment="Right" Fill="Black"/>
                          
      </Grid>
      
      <StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Grid.Row="3">
         <Button Name="btnStartUser" Content="Start user" Click="btnStartUser_Click" />
         <Button Name="btnStartComputer" Content="Start computer" Click="btnStartComputer_Click"/>
      </StackPanel>
      
   </Grid>
</Page>

与 Win32 桌面版游戏不同,在 Windows Runtime 应用中,我们不必显式处理所有 UI 的绘制,但仍然必须显式创建 UI 元素。例如,当用户在游戏期间单击某个单元格时,我们必须创建一个代表标记的 UI 元素。为此,我将使用与桌面版本相同的位图(`ttto.bmp` 和 `tttx.bmp`)并在 `Image` 控件中显示它们。我还会绘制一条红线来覆盖获胜的行、列或对角线,为此,我将使用 `Line` 形状。

我们可以直接将 `tictactoe_game` 的源代码(`game.h`、`game.cpp`、`strategy_x.h` 和 `strategy_o.h`)添加到项目中。或者,我们可以从单独的、通用的 DLL 中导出它们,但为了简单起见,我将只使用相同的源文件。然后,我们只需将 `tictactoe_game` 类型的对象添加到 `MainPage` 类中。

#pragma once

#include "MainPage.g.h"
#include "..\Common\game.h"

namespace TicTacToeWinRT
{
   public ref class MainPage sealed
   {
   private:
      tictactoe_game game;

      // ... 
   };
}      

我们基本上需要实现三个事件处理程序

  • “**开始用户**”按钮的 `Clicked` 事件处理程序
  • “**开始计算机**”按钮的 `Clicked` 事件处理程序
  • 棋盘网格的 `PointerReleased` 事件处理程序,在指针(鼠标、手指)从网格上释放时调用。

两个按钮单击处理程序的逻辑与我们在 Win32 桌面应用程序中所做的非常相似。首先,我们必须重置游戏(稍后将详细说明这意味着什么)。如果用户先开始,那么我们只需用正确的策略初始化游戏对象。如果计算机先开始,除了策略初始化之外,我们还让计算机实际走棋,然后显示计算机走棋的标记。

void TicTacToeWinRT::MainPage::btnStartUser_Click(Object^ sender, RoutedEventArgs^ e)
{
   ResetGame();

   game.start(tictactoe_player::user);
}

void TicTacToeWinRT::MainPage::btnStartComputer_Click(Object^ sender, RoutedEventArgs^ e)
{
   ResetGame();

   game.start(tictactoe_player::computer);
   auto cell = game.move(tictactoe_player::computer);
   
   PlaceMark(cell, tictactoe_player::computer);
}

`PlaceMark()` 方法会创建一个新的 `Image` 控件,将其 `Source` 设置为 `tttx.bmp` 或 `ttt0.bmp`,并将图像控件添加到棋盘网格中走棋的单元格。

void TicTacToeWinRT::MainPage::PlaceMark
      (tictactoe_cell const cell, tictactoe_player const player)
{
   auto image = ref new Image();
   auto bitmap = ref new BitmapImage(
      ref new Uri(player == tictactoe_player::computer ? 
      "ms-appx:///Assets/tttx.bmp" : "ms-appx:///Assets/ttt0.bmp"));
   bitmap->ImageOpened += ref new RoutedEventHandler( 
      [this, image, bitmap, cell](Object^ sender, RoutedEventArgs^ e) {
         image->Width = bitmap->PixelWidth;
         image->Height = bitmap->PixelHeight;
         image->Visibility = Windows::UI::Xaml::Visibility::Visible;
   });

   image->Source = bitmap;

   image->Visibility = Windows::UI::Xaml::Visibility::Collapsed;
   image->HorizontalAlignment = Windows::UI::Xaml::HorizontalAlignment::Center;
   image->VerticalAlignment = Windows::UI::Xaml::VerticalAlignment::Center;

   Grid::SetRow(image, cell.row);
   Grid::SetColumn(image, cell.col);

   board->Children->Append(image);
}

当新游戏开始时,在游戏中添加到网格的这些 `Image` 控件必须被移除。这就是 `ResetGame()` 方法的作用。此外,它还会移除显示在获胜线上的红线以及显示游戏结果的文本。

void TicTacToeWinRT::MainPage::ResetGame()
{
   std::vector<Windows::UI::Xaml::UIElement^> children;

   for(auto const & child : board->Children)
   {
      auto typeName = child->GetType()->FullName;
      if(typeName == "Windows.UI.Xaml.Controls.Image" ||
         typeName == "Windows.UI.Xaml.Shapes.Line")
      {
         children.push_back(child);
      }
   }

   for(auto const & child : children)
   {
      unsigned int index;
      if(board->Children->IndexOf(child, &index))
      {
         board->Children->RemoveAt(index);
      }
   }

   txtStatus->Text = nullptr;
}

当用户在棋盘网格的单元格上按下指针时,如果该单元格为空,我们就走棋。如果此时游戏尚未结束,我们就让计算机走棋。当游戏在计算机或用户走棋后结束时,我们在文本框中显示结果,如果其中一方获胜,我们就绘制一条红线来覆盖获胜的行、列或对角线。

void TicTacToeWinRT::MainPage::board_PointerReleased
    (Platform::Object^ sender, Windows::UI::Xaml::Input::PointerRoutedEventArgs^ e)
{
   if(game.is_started() && ! game.is_finished())
   {
      auto cellw = board->ActualWidth / 3;
      auto cellh = board->ActualHeight / 3;

      auto point = e->GetCurrentPoint(board);
      auto row = static_cast<int>(point->Position.Y / cellh);
      auto col = static_cast<int>(point->Position.X / cellw);

      game.move(tictactoe_cell(row, col), tictactoe_player::user);
      PlaceMark(tictactoe_cell(row, col), tictactoe_player::user);

      if(!game.is_finished())
      {
         auto cell = game.move(tictactoe_player::computer);
         PlaceMark(cell, tictactoe_player::computer);

         if(game.is_finished())
         {
            DisplayResult(
               game.is_victory(tictactoe_player::computer) ? 
               tictactoe_player::computer :
               tictactoe_player::none);
         }
      }
      else
      {
         DisplayResult(
            game.is_victory(tictactoe_player::user) ? 
            tictactoe_player::user :
            tictactoe_player::none);
      }
   }
}

void TicTacToeWinRT::MainPage::DisplayResult(tictactoe_player const player)
{
   Platform::String^ text = nullptr;
   switch (player)
   {
   case tictactoe_player::none:
      text = "It's a draw!";
      break;
   case tictactoe_player::computer:
      text = "Computer wins!";
      break;
   case tictactoe_player::user:
      text = "User wins!";
      break;
   }

   txtStatus->Text = text;

   if(player != tictactoe_player::none)
   {
      auto coordinates = game.get_winning_line();
      if(coordinates.first.is_valid() && coordinates.second.is_valid())
      {
         PlaceCut(coordinates.first, coordinates.second);
      }
   }
}

void TicTacToeWinRT::MainPage::PlaceCut(tictactoe_cell const start, tictactoe_cell const end)
{
   auto cellw = board->ActualWidth / 3;
   auto cellh = board->ActualHeight / 3;

   auto line = ref new Line();
   line->X1 = start.col * cellw + cellw / 2;
   line->Y1 = start.row * cellh + cellh / 2;

   line->X2 = end.col * cellw + cellw / 2;
   line->Y2 = end.row * cellh + cellh / 2;

   line->StrokeStartLineCap = Windows::UI::Xaml::Media::PenLineCap::Round;
   line->StrokeEndLineCap = Windows::UI::Xaml::Media::PenLineCap::Round;
   line->StrokeThickness = 15;
   line->Stroke = ref new SolidColorBrush(Windows::UI::Colors::Red);

   line->Visibility = Windows::UI::Xaml::Visibility::Visible;

   Grid::SetRow(line, 0);
   Grid::SetColumn(line, 0);
   Grid::SetRowSpan(line, 3);
   Grid::SetColumnSpan(line, 3);

   board->Children->Append(line);
}

好了,就这些了。您可以构建应用程序,开始玩游戏。看起来是这样的

结论

在本文中,我们学习了如何使用不同的技术在 C++ 中使用不同的用户界面创建一个简单的游戏。我们只编写了一次游戏逻辑,使用标准 C++,然后用它来构建两个使用完全不同技术的应用程序:Win32 API,我们必须为创建窗口和绘制窗口等事情做更多显式工作;以及使用 XAML 的 Windows Runtime,其中框架完成了大部分工作,我们可以专注于游戏逻辑(并且当我们设计 UI 时,我们以一种声明式的方式进行,不仅在 XAML 中,还在代码隐藏中)。除其他外,我们还看到了如何使用标准容器,如 `std::array` 和 `std::set`,以及我们如何无缝地在 Windows Runtime 的 C++/CX 应用程序中使用纯 C++ 逻辑代码。

历史

  • 2013 年 11 月 4 日:初始版本
© . All rights reserved.