C++ 很有趣:编写一个井字游戏 (Tic Tac Toe)

英文原文:C++ is fun: Writing a Tic Tac Toe Game



这个有趣的C++系列打算展示一下使用C++写代码可以和其他主流语言一样高效而有趣。在第二部分,我将向你展示使用C++从无到有的创建一个井字游戏。这篇文章,以及整个系列都是针对那些想学习C++或者对这个语言性能好奇的开发者。

  许多年轻人想学习编程来写游戏。C++是用的最多的用来写游戏的语言,尽管在写出下个愤怒的小鸟之前,需要学会很多的编程经验。一个井子游戏是开始学习的一个好选择,事实上,在许多年前我开始学习C++后,他是我写的地一个游戏。我希望这篇文章可以帮助到那些还不熟悉C++的初学者和有经验的开发者。

  我使用的是Visual Studio 2012来写这篇文章的源代码。

 游戏介绍

  如果你没有玩过井字游戏或者并不熟悉这个游戏,下面是来自维基百科的描述.

井字游戏 (或者"圈圈和叉叉",Xs and Os) 是一个两人的纸笔游戏,两个人轮流在3X3的网格内画圈和叉. 当一名玩家放置的标志在水平,垂直或者对角线上成一条线即获得胜利.

  这个游戏也可以人机对战,先手不固定.

  创建这个程序的时候有2个关键的东西:程序的逻辑和程序的UI界面. 有许多在windows中创建用户UI的方法, 包括 Win32 API, MFC, ATL, GDI+, DirectX, etc. 在这篇文章中,我将展示使用多种技术来实现同一个程序逻辑. 我们将新建2个应用, 一个使用 Win32 API 另一个使用 C++/CX.

  游戏逻辑

  如果一个玩家在网格上放下一个标记时,遵循几个简单的规则,那他就可以玩一个完美的游戏(意味着赢或者平局)。在Wikipedia上写有这些规则,在里面你也可以找到先手玩家的最优策略。

  在xkcd drawing上有先手和后手玩家的最优策略。尽管有几个错误(在几种情况下没有走必胜的步骤,至少在一个情况下丢失了一个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格板。这是个固定大小的容器,在编译时就已知的大小,在连续的内存区域存储元素。为了避免一遍又一遍的使用相同数组类型,我将定义一个别名来简化。

1
2
3
#include <array>
 
typedef std::array< char , 9> tictactoe_status;

  上面描述的最优策略用这样的数组队列(另一个数组)来表示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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是后手玩家的最优策略。如果你看了文中的源代码,你将注意到这两个数组的真实定义和我前面展示的不同。

1
2
3
4
5
6
7
8
9
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文件的片断。

1
2
3
4
5
6
7
8
9
10
// 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中支持。

1
2
3
4
5
6
7
8
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的枚举类型变量。

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

  游戏的逻辑部分将会在被称之为tictactoe_game 的类中实现。最基本的,这个 class 应该有下面的状态:

  • 一个布尔值用来表示游戏是否开始了,命名为 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 
{

  对于方法is_victory()和is_finished(),实际上是依赖于两个私有的方法,is_full(), 用来表示网格是否被填满并且不能再放下任何的棋子,以及方法is_winning, 用来表示在该网格上是否有某玩家胜出。它们的实现将会很容易被读懂。is_full 通过计算网格中空的(在表示网格的数组中值为0)格子的数量,如果没有这样的格子那么将返回true。is_winning将会检查这些连线,网格的行、列、以及对角线,依此来查看是否有哪位玩家已经获胜。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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很相似,它检查行、列和对角线上的状态。它可能会看起来有点冗长,但是我相信这个方法比使用循环来遍历行、列、对角线更加简单。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
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()对开始新游戏进行初始化。可以下的棋的策略的集合被再一次的初始化, 从stategy_x 或者strategy_o进行拷贝。从下面的代码可以注意到,strategy是一个std::set, 并且strategy_x或者strategy_o都是有重复单元的数组,因为在tictoctoe表里面的一些位置是重复的。这个std::set 是一个只包含唯一值的容器并且它保证了唯一的可能的位置(例如对于strategy_o来说,有一半是重复的)。<algorithm> 中的std::copy算法在这里被用来进行数据单元的拷贝,将当前的内容拷贝到std::set中,并且使用方法assign()来将std::array的所有的元素重置为0。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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()方法的接收参数是网格的坐标、玩家的记号,如果这一步有效时返回真,否则返回假。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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()方法在可能的步骤(策略)集合中查询,然后从中选择最佳的一步。在走完这步后,会检查电脑是否赢得这场游戏,如果是的话标记游戏结束。这个方法返回电脑走下一步的位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
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来表示并且当前情况下任何的走法都是可能的。这也是为什么我们要在thestart()方法里把整个步数都拷贝出来的原因。一旦玩家走了一步,那能走的步数的set便会减少。举个例子,玩家在第一个格子里走了一步。此时网格编码为1,0,0,0,0,0,0,0,0。这时在数组的第一个位置不可能再有0或者2的走法因此需要被过滤掉。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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,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。然而我们需要选择最优的走法而不是仅仅只随便走一步,通常最优的走法也是赢得比赛的关键。因此我们需要找一步能使我们走向胜利,如果没有这样的一步,那就随便走吧。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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.hgame.cpp中的代码

 一个用Win32 API实现的游戏

  我将用Win32 API做用户界面来创建第一个应用程序。如果你不是很熟悉Win32 编程那么现在已经有大量的资源你可以利用学习。为了使大家理解我们如何创建一个最终的应用,我将只讲述一些必要的方面。另外,我不会把每一行代码都展现并解释给大家,但是你可以通过下载这些代码来阅读浏览它。

  一个最基本的Win32应用需要的一些内容:

  • 一个入口点,通常来说是WinMain,而不是main。它需要一些参数例如当前应用实例的句柄,命令行和指示窗口如何展示的标志。
  • 一个窗口类,代表了创建一个窗口的模板。一个窗口类包含了一个为系统所用的属性集合,例如类名,class style(不同于窗口的风格),图标,菜单,背景刷,窗口的指针等。一个窗口类是进程专用的并且必须要注册到系统优先级中来创建一个窗口。使用RegisterClassEx来注册一个窗口类。
  • 一个主窗口,基于一个窗口类来创建。使用CreateWindowEx可以创建一个窗口。
  • 一个窗口过程函数,它是一个处理所有基于窗口类创建的窗口的消息的方法。一个窗口过程函数与窗口相联,但是它不是窗口。
  • 一个消息循环。一个窗口通过两种方式来接受消息:通过SendMessage,直接调用窗口过程函数直到窗口过程函数处理完消息之后才返回,或者通过PostMessage (或 PostThreadMessage)把一个消息投送到创建窗口的线程的消息队列中并且不用等待线程处理直接返回。因此线程必须一直运行一个从消息队列接收消息和把消息发送给窗口过程函数的循环

  你可以在 MSDN 中找到关于Win 32 应用程序如何注册窗口类、创建一个窗口、运行消息循环的例子。一个Win32的应用程序看起来是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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;  
}

  当然,这还不够,我们还需要一个窗口过程函数来处理发送给窗口的消息,比如PAINT消息,DESTORY 消息,菜单消息和其它的一些必要的消息。一个窗口过程函数看起来是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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类是对窗口类相关资源初始化的封装,在构造函数中,它初始化了WNDCLASSEX 的结构并且调用 RegisterClassEx 方法。在析构函数中,它通过调用 UnregisterClass 移除窗口的注册。
  • Window类是通过对HWND封装一些诸如Create,ShowWindow 和Invalidate的函数(它们的名字已经告诉了你他们是做什么的)。它还有几个虚成员代表消息句柄,它们会被窗口过程调用 (OnPaint,OnMenuItemClicked,OnLeftButtonDown) 。这个window类将会被继承来并提供具体的实现。
  • DeviceContex类是对设备描述表(HDC)的封装。在构造函数中它调用 BeginPaint 函数并且在析构函数中调用  EndPaint 函数。

  这个游戏的主要窗口是TicTacToeWindow类,它是从Window类继承而来,它重载了虚拟方法来处理消息,该类的声明是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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;
};

  MethodOnPaint()函数用来绘制窗口,它用来绘制窗口背景,网格线,填充的单元格(如果有的话),如果在游戏结束,玩家赢了,一条红线在获胜行,列或对角线的 标记。为了避免闪烁,我们使用了双缓冲技术:创建一个内存设备文本(通过调用toBeginPaint函数准备窗口的设备文本来匹配),一个内存中的位图匹配内存设备文本,绘制该位图,然后用窗口设备文本来复制内存设备文本。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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,DrawGridand和 DrawMarksfunctions的内容。他们不是很复杂,你可以阅读源代码。DrawMarksfunction使用两个位图,ttt0.bmp和tttx.bmp,绘制网格的痕迹。

  我将只显示如何在获胜行,列或对角线绘制红线。首先,我们要检查游戏是否结束,如果结束那么检索获胜线。如果两端都有效,然后计算该两个小区的中心,创建和选择一个画笔(实心,15像素宽的红色线)并且绘制两个小区的中间之间的线。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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用来关闭应用。当用户点击两个启动中的任何一个,我们就必须开始一个游戏任务。如果电脑先移动,那么我们应该是否移动,并且,在所有情况中,都要重新绘制窗口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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 ;
    }
}

  现在只剩下一件事了,就是留意在我们的窗口中处理用户单击鼠标的行为。当用户在我们的窗口客户区内按下鼠标时,我们要去检查是鼠标按下的地方是在哪一个网格内,如果这个网格是空的,那我们就把用户的标记填充上去。之后,如果游戏没有结束,就让电脑进行下一步的移动。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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函数,这是整个程序的入口点。下面的代码与这部分开始我给出的代码非常相似,不同的之处是它使用了我对窗口和窗口类进行封装的一些类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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的游戏app

  Windows Runtime是Windows 8引入的一个新的Windows运行时引擎. 它依附于Win32并且有一套基于COM的API. 为Windows Runtime创建的app通常很糟糕,被人称为"Windows商店" 应用. 它们运行在Windows Runtime上, 而不是Windows商店里, 但是微软的市场营销人员可能已经没什么创造力了. Windows Runtime 应用和组件可以用C++实现,不管是用Windows Runtime C++ Template Library (WTL) 或者用 C++ Component Extensions (C++/CX)都可以. 在这里我将使用XAML和C++/CX来创建一个功能上和我们之前实现的桌面版应用类似的应用。

  当你创建一个空的Windows Store XAML应用时向导创建的项目实际上并不是空的, 它包含了所有的Windows Store应用构建和运行所需要的文件和配置。但是这个应用的main page是空的。

  我们要关心对这篇文章的目的,唯一的就是主界面。 XAML代码可以在应用在文件MainPage.xaml中,和背后的MainPage.xaml.h MainPage.xaml.cpp的代码。,我想建立简单的应用程序如下图。

  下面是XAML的页面可能看起来的样子(在一个真实的应用中,你可能要使用应用程序栏来操作,如启动一个新的游戏,主页上没有按键,但为了简单起见,我把它们在页面上)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
< 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元素来表示一个标记。为此,我会用在桌面版(too.bmp and ttx.bmp)中用过的位图,并且在图像控件中显示它们.。我还会在获胜的行、列、或对角线上画一个红色的线,为此,我会用到Lineshape类。

  我们可以直接把tictactoe_game的源代码(game.hgame.cppstrategy_x.h and strategy_o.h)添加到工程里。或者我们可以把它们导出成一个单独的DLL。为了方便,我使用了相同的源文件。然后我们必须添加一个tictactoe_game对象到MainPage类中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#pragma once
 
#include "MainPage.g.h"
#include "..\Common\game.h"
 
namespace TicTacToeWinRT
{
    public ref class MainPage sealed
    {
    private :
       tictactoe_game game;
 
       // ...
    };
}

  这里有3类基本的事件处理handler需要我们自己实现:

  • 处理“Start user”按钮的theClickedevent事件的handler
  • 处理“Start computer”按钮的theClickedevent事件的handler
  • 处理面板网格的thePointerReleasedevent事件的handler,当指针(鼠标或者手势)从网格释放时被调用。

  对这两个按钮点击的handler,在逻辑上与我们在Win32桌面应用中实现的类似。首先,我们必须要重置游戏(一会会看到这代表什么意思)。如果玩家先开始,那么我们仅仅只需要用正确的策略来初始化游戏对象。如果是电脑先开始,那我们除了要初始化策略,还要让电脑呈现出真正走了一步并且在电脑走的那一步的单元格上做上标记。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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()方法创建了一个newImagecontrol控件,设定它的Source是tttx.bmp或者ttt0.bmp,并且把它添加到所走的那一步的面板网格上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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);
}

  当开始一场新游戏时,这些在游戏过程中被添加到网格上的Imagecontrol控件需要被移除掉。这正是theResetGame()method方法所做的事情。此外,它还移除了游戏胜利时显示的红线和显示游戏结果的文字。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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;
}

  当玩家在一个单元格上点击了一下指针,并且这个单元格是没有被占据的,那我们就让他走这一步。如果这时游戏还没有结束,那我们也让电脑走一步。当游戏在玩家或者电脑走过一步之后结束,我们会在一个text box中显示结果并且如果有一方胜利,会在胜利的行,列或对角上划上红线。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
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);
}

  到这里就全部结束了,你可以build它了,启动它然后玩吧。它看起来像这样:

 总结

  在这篇文章中,我们已经看到了,我们可以在C + +中使用不同的用户界面和使用不同的技术创建一个简单的游戏。首先我们写游戏思路是使用标准的C + +,来用它来构建使用完全不同的技术创建的两个应用程序:在这里我们不得不使用Win32 API来做一些工作,比如创建一个绘制窗口,并且能够在运行时使用XAML。在那里,框架做了大部分的工作,所以我们可以专注于游戏逻辑(在后面的代码里我必须说明我们不得不去设计UI,而不仅仅使用XAML)。其中包括我们看到的如何能够使用标准的容器asstd:: arrayandstd:: setand,我们如何使用纯C++逻辑代码在C++/CX中完美的运行。

  原文地址:http://www.codeproject.com/Articles/678078/Cplusplus-is-fun-Writing-a-Tic-Tac-Toe-Game

  • 0
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值