C++ 很有趣:編寫一個井字遊戲 (Tic Tac Toe)

oschina發表於2013-12-13

  這個有趣的C++系列打算展示一下使用C++寫程式碼可以和其他主流語言一樣高效而有趣。在第二部分,我將向你展示使用C++從無到有的建立一個井字遊戲。這篇文章,以及整個系列都是針對那些想學習C++或者對這個語言效能好奇的開發者。

  許多年輕人想學習程式設計來寫遊戲。C++是用的最多的用來寫遊戲的語言,儘管在寫出下個憤怒的小鳥之前,需要學會很多的程式設計經驗。一個井子游戲是開始學習的一個好選擇,事實上,在許多年前我開始學習C++後,他是我寫的地一個遊戲。我希望這篇文章可以幫助到那些還不熟悉C++的初學者和有經驗的開發者。

  我使用的是Visual Studio 2012來寫這篇文章的原始碼。

 遊戲介紹

  如果你沒有玩過井字遊戲或者並不熟悉這個遊戲,下面是來自維基百科的描述.

井字遊戲 (或者"圈圈和叉叉",Xs and Os) 是一個兩人的紙筆遊戲,兩個人輪流在3X3的網格內畫圈和叉. 當一名玩家放置的標誌在水平,垂直或者對角線上成一條線即獲得勝利.

C++ 很有趣:編寫一個井字遊戲 (Tic Tac Toe)

  這個遊戲也可以人機對戰,先手不固定.

  建立這個程式的時候有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格板。這是個固定大小的容器,在編譯時就已知的大小,在連續的記憶體區域儲存元素。為了避免一遍又一遍的使用相同陣列型別,我將定義一個別名來簡化。

#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 的類中實現。最基本的,這個 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將會檢查這些連線,網格的行、列、以及對角線,依此來檢視是否有哪位玩家已經獲勝。

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()對開始新遊戲進行初始化。可以下的棋的策略的集合被再一次的初始化, 從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。

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()方法的接收引數是網格的座標、玩家的記號,如果這一步有效時返回真,否則返回假。

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來表示並且當前情況下任何的走法都是可能的。這也是為什麼我們要在thestart()方法裡把整個步數都拷貝出來的原因。一旦玩家走了一步,那能走的步數的set便會減少。舉個例子,玩家在第一個格子裡走了一步。此時網格編碼為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,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.hgame.cpp中的程式碼

 一個用Win32 API實現的遊戲

  我將用Win32 API做使用者介面來建立第一個應用程式。如果你不是很熟悉Win32 程式設計那麼現在已經有大量的資源你可以利用學習。為了使大家理解我們如何建立一個最終的應用,我將只講述一些必要的方面。另外,我不會把每一行程式碼都展現並解釋給大家,但是你可以通過下載這些程式碼來閱讀瀏覽它。

  一個最基本的Win32應用需要的一些內容:

  • 一個入口點,通常來說是WinMain,而不是main。它需要一些引數例如當前應用例項的控制程式碼,命令列和指示視窗如何展示的標誌。
  • 一個視窗類,代表了建立一個視窗的模板。一個視窗類包含了一個為系統所用的屬性集合,例如類名,class style(不同於視窗的風格),圖示,選單,背景刷,視窗的指標等。一個視窗類是程式專用的並且必須要註冊到系統優先順序中來建立一個視窗。使用RegisterClassEx來註冊一個視窗類。
  • 一個主視窗,基於一個視窗類來建立。使用CreateWindowEx可以建立一個視窗。
  • 一個視窗過程函式,它是一個處理所有基於視窗類建立的視窗的訊息的方法。一個視窗過程函式與視窗相聯,但是它不是視窗。
  • 一個訊息迴圈。一個視窗通過兩種方式來接受訊息:通過SendMessage,直接呼叫視窗過程函式直到視窗過程函式處理完訊息之後才返回,或者通過PostMessage (或 PostThreadMessage)把一個訊息投送到建立視窗的執行緒的訊息佇列中並且不用等待執行緒處理直接返回。因此執行緒必須一直執行一個從訊息佇列接收訊息和把訊息傳送給視窗過程函式的迴圈

  你可以在 MSDN 中找到關於Win 32 應用程式如何註冊視窗類、建立一個視窗、執行訊息迴圈的例子。一個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;   
}

  當然,這還不夠,我們還需要一個視窗過程函式來處理髮送給視窗的訊息,比如PAINT訊息,DESTORY 訊息,選單訊息和其它的一些必要的訊息。一個視窗過程函式看起來是這樣的:

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類繼承而來,它過載了虛擬方法來處理訊息,該類的宣告是這樣的:

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函式準備視窗的裝置文字來匹配),一個記憶體中的點陣圖匹配記憶體裝置文字,繪製該點陣圖,然後用視窗裝置文字來複制記憶體裝置文字。

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);
}

C++ 很有趣:編寫一個井字遊戲 (Tic Tac Toe)

  我不會在這裡列出DrawBackground,DrawGridand和 DrawMarksfunctions的內容。他們不是很複雜,你可以閱讀原始碼。DrawMarksfunction使用兩個點陣圖,ttt0.bmp和tttx.bmp,繪製網格的痕跡。

C++ 很有趣:編寫一個井字遊戲 (Tic Tac Toe)C++ 很有趣:編寫一個井字遊戲 (Tic Tac Toe)

  我將只顯示如何在獲勝行,列或對角線繪製紅線。首先,我們要檢查遊戲是否結束,如果結束那麼檢索獲勝線。如果兩端都有效,然後計算該兩個小區的中心,建立和選擇一個畫筆(實心,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的遊戲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的程式碼。,我想建立簡單的應用程式如下圖。

C++ 很有趣:編寫一個井字遊戲 (Tic Tac Toe)

  下面是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元素來表示一個標記。為此,我會用在桌面版(too.bmp and ttx.bmp)中用過的點陣圖,並且在影像控制元件中顯示它們.。我還會在獲勝的行、列、或對角線上畫一個紅色的線,為此,我會用到Lineshape類。

  我們可以直接把tictactoe_game的原始碼(game.h, game.cpp, strategy_x.h and 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;

      // ... 
   };
}      

  這裡有3類基本的事件處理handler需要我們自己實現:

  • 處理“Start user”按鈕的theClickedevent事件的handler
  • 處理“Start computer”按鈕的theClickedevent事件的handler
  • 處理皮膚網格的thePointerReleasedevent事件的handler,當指標(滑鼠或者手勢)從網格釋放時被呼叫。

  對這兩個按鈕點選的handler,在邏輯上與我們在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()方法建立了一個newImagecontrol控制元件,設定它的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);
}

  當開始一場新遊戲時,這些在遊戲過程中被新增到網格上的Imagecontrol控制元件需要被移除掉。這正是theResetGame()method方法所做的事情。此外,它還移除了遊戲勝利時顯示的紅線和顯示遊戲結果的文字。

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中顯示結果並且如果有一方勝利,會在勝利的行,列或對角上劃上紅線。

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

相關文章