用 OpenCV 寫一個 UNO 小遊戲

源代碼已在 GitHub 上公開。倉庫地址:https://github.com/shiawasenahikari/UnoCard

第一步:準備卡牌圖片資源

進入維基百科 UNO 詞條,下載矢量圖資源。地址:https://commons.wikimedia.org/wiki/File:UNO_cards_deck.svg

下載完成後,將其轉爲 PNG 位圖,並對圖像進行切割,提取出其中的每一張卡牌圖像。然後對提取出的卡牌再做調整大小、調色等處理,最終得到以下位圖資源。爲了良好的屏幕顯示效果,這裏所有的卡牌圖像大小均調整爲 121x181。(可以到我的 github 倉庫裏提取已經做好的圖片素材,地址:https://github.com/shiawasenahikari/UnoCard/tree/master/UnoCard/resource

我把除牌背外的每張牌都額外做了一次暗色處理,準備在畫面顯示中,用暗色牌表示“不可出的牌”,用亮色牌代表“可以出的牌”。另外,帶顏色的黑牌也沒有做暗色處理。這裏帶顏色的黑牌只用來指示打出這張牌的玩家指定的接下來的合法顏色是什麼,只會出現在“最近打出的牌”裏,而不會出現在玩家的手牌裏。實際的 108 張牌裏並沒有真正意義上的“帶顏色的黑牌”。

第二步:新建 VS 項目,導入 OpenCV 庫

打開你的 Visual Studio 軟件,新建一個 Win32 C++ 控制檯應用程序。

在 Win32 應用程序嚮導裏,勾選【空項目】複選框,然後點擊【完成】。

在你的解決方案根目錄裏,建立如下的目錄結構

UnoCard(解決方案根目錄)

└─UnoCard(源代碼根目錄)

    ├─include(頭文件根目錄)

    ├─lib(庫目錄)

    ├─resource(卡牌資源目錄)

    └─src(cpp 文件根目錄)

然後,把你準備好的圖片資源文件複製到 resource 目錄裏。

接下來導入 OpenCV 庫。這裏以 4.1.2 版本的 OpenCV 爲例,其他版本(3.0 及以上)的配置方法大同小異。

1) 進入 OpenCV 官網(地址:https://opencv.org/releases)下載 Windows 版的 OpenCV。如圖所示,點擊那個 Windows 按鈕即可開始下載。

2) 下載完成後會得到一個自解壓文件,將其解壓到任意目錄(會在指定目錄下自動創建 opencv 子目錄),假設解壓到了 D:\ 目錄。然後我們依次複製以下文件到我們的解決方案目錄裏:

將 D:\opencv\build\include\opencv2 目錄複製到解決方案根目錄下的 UnoCard\include 目錄裏。

將 D:\opencv\build\x64\vc14\lib 目錄中的 opencv_world412.lib 文件複製到解決方案根目錄下的 UnoCard\lib 目錄裏。注意文件名裏的數字,因爲這裏下載的是 4.1.2 版本的 OpenCV,所以文件名裏的數字是 412。如果你下載了其他版本的 OpenCV,則文件名裏的數字也會有所不同。下面複製的 dll 文件同理。(這裏默認你用的是 Visual Studio 2015。如果你用的是 Visual Studio 2017,則改爲從 D:\opencv\build\x64\vc15\lib 目錄中複製。)

將 D:\opencv\build\x64\vc14\bin 目錄中的 opencv_world412.dll 文件複製到解決方案根目錄下的 UnoCard 目錄裏。(這裏默認你用的是 Visual Studio 2015。如果你用的是 Visual Studio 2017,則改爲從 D:\opencv\build\x64\vc15\bin 目錄中複製。)

第三步:在項目中配置 OpenCV

1) 在 Visual Studio 的右側【解決方案資源管理器】裏,右擊 UnoCard,選擇【屬性】。

2) 點擊右上角的【配置管理器】,然後刪除 Debug 配置和 Win32 平臺,只保留 Release 配置和 x64 平臺。

3) 關閉配置管理器後,點擊項目屬性對話框左側的【VC++ 目錄】,配置 include 目錄和庫目錄。其中,在【包含目錄】中添加 $(MSBuildProjectDirectory)\include 目錄,在【庫目錄】中添加 $(MSBuildProjectDirectory)\lib 目錄。

4) 點擊左側的【鏈接器】→【輸入】,然後在右側的【附加依賴項】中添加 opencv_world412.lib。注意你導入的是哪個版本的 OpenCV,要和對應的庫文件的文件名一致。這裏我們下載的是 4.1.2 版本,所以文件名是 opencv_world412.lib。

5) 配置完成後點【確定】,然後我們來檢查一下配置是否成功。在解決方案資源管理器中右擊【源文件】,然後選擇【添加】→【新建項】,然後在解決方案根目錄下的 UnoCard\src 目錄中新建 main.cpp。

6) 在 main.cpp 中輸入如下代碼:

#include <opencv2/highgui.hpp>

int main() {
	cv::Mat image = cv::imread("resource/front_r0.png");
	cv::namedWindow("Uno");
	cv::imshow("Uno", image);
	cv::waitKey(0);
	cv::destroyAllWindows();
	return 0;
} // main()

7) 按 Ctrl+F5 開始執行,如果彈出一個窗口顯示了一張 Uno 卡牌,就說明 OpenCV 配置成功了。然後按任意鍵退出。

第四步:開始設計程序

這裏使用面向對象的設計思想,將整個程序分爲兩部分:主程序部分、核心部分。其中主程序部分負責屏幕顯示及人機交互,核心部分用於管理各玩家的卡牌資源,以及控制遊戲實時狀態,由四個子模塊構成:卡牌類、玩家類、UNO 運行時類、AI 類。

4.1 卡牌類——Card class

顧名思義,一個卡牌類對象裏存儲的就是一張牌的基本信息。在設計卡牌類之前,我們需要先定義兩個新的枚舉類型,用於標識一張 UNO 牌裏的兩個元素——顏色、內容。其中顏色有紅、黃、藍、綠,以及無色(黑牌打出前視爲無色,打出後視爲打出此牌的玩家所指定的顏色)五種,內容有數字 0~9、反轉、禁止、+2、變色、變色且 +4 共 15 種。對顏色和內容的枚舉類型的聲明分別放置在 include\Color.h 和 include\Content.h 頭文件中。

// Color.h

#pragma once

typedef enum {
	NONE, RED, BLUE, GREEN, YELLOW
} Color;



// Content.h

#pragma once

typedef enum {
	NUM0, NUM1, NUM2, NUM3, NUM4,
	NUM5, NUM6, NUM7, NUM8, NUM9,
	REV, SKIP, DRAW2, WILD, WILD_DRAW4
} Content;

然後我們開始設計卡牌類。對該類的聲明和定義分別放在 include\Card.h 頭文件和 src\Card.cpp 實現文件裏。(注意,本篇文章裏只是實現一個最小模型,實際一個對象裏不見得只能存儲這些信息,完全可以進一步拓展。具體該如何拓展,可以參考我的 GitHub 倉庫裏的代碼。)

// Card.h

#pragma once

#include <string>
#include <Color.h>
#include <Content.h>
#include <opencv2/core.hpp>

class Card {
public:
	const cv::Mat image;      // 卡牌高亮圖案
	const cv::Mat darkImg;    // 卡牌暗色圖案
	const Content content;    // 卡牌內容,例:NUM3
	const std::string name;   // 卡牌名字,例:"Blue 3"
	Color getColor();         // 返回卡牌顏色
	bool isWild();            // 該牌是否爲黑牌

private:
	friend class Uno;         // 授權 UNO 運行時類訪問本類私有字段
	const int order;          // 卡牌序號,排序時用
	Color color;              // 卡牌顏色,例:BLUE。該屬性可寫,但只能被 UNO 運行時類改寫

	// 構造器,該類的實例僅可被 UNO 運行時類創建
	Card(const cv::Mat&, const cv::Mat&, Color, Content, const std::string&);
}; // Card Class
// Card.cpp

#include <Card.h>

// 構造器,該類的實例僅可被 UNO 運行時類創建
Card::Card(const cv::Mat& image, const cv::Mat& darkImg,
	Color color, Content content, const std::string& name) :
	name(name),
	image(image),
	color(color),
	content(content),
	darkImg(darkImg),
	order((color << 4) + content) {
} // Card(Mat&, Mat&, Color, Content, string&) (Class Constructor)

// 返回卡牌顏色
Color Card::getColor() {
	return color;
} // getColor()

// 該牌是否爲黑牌
bool Card::isWild() {
	// 【變色】及【變色 +4】爲黑牌,可以不受上一張打出的牌的限制,任意跟出。
	// 其他有顏色的牌只能按同色或同內容來跟。
	return content == WILD || content == WILD_DRAW4;
} // isWild()

這裏,卡牌的顏色屬性字段 color 設置爲了私有,同時提供了一個公有函數 getColor() 來獲取卡牌顏色。首先,color 屬性不能設置爲 const,因爲黑牌的顏色是可變的——打出前視爲無色,打出後視爲打出該牌的玩家所指定的顏色。其次,color 屬性不能暴露給外部,否則有被篡改的可能。所以就這樣封裝了一下,在類內部以及 UNO 運行時類裏,color 字段是可改寫的;在其他地方看來,color 字段是隻讀的。

4.2 玩家類——Player class

用於存儲每位玩家的實時信息,如玩家的手牌,玩家最近一輪的動作(是摸牌還是出牌,如果是出牌,則出了哪張牌)。對該類的聲明和定義分別放在 include\Player.h 頭文件和 src\Player.cpp 實現文件裏。

// Player.h

#pragma once

#include <vector>
#include <Card.h>

class Player {
public:
	static const int YOU = 0;            // 你的 ID
	static const int COM1 = 1;           // 西家的 ID
	static const int COM2 = 2;           // 北家的 ID
	static const int COM3 = 3;           // 東家的 ID
	const std::vector<Card*>& getHand(); // 返回所有手牌
	Card* getRecent();                   // 返回最近一輪出的牌,或 nullptr 表最近一輪摸了牌

private:
	friend class Uno;                   // 授權 UNO 運行時類訪問本類私有字段
	std::vector<Card*> hand;            // 手牌列表
	Card* recent;                       // 最近一輪的出牌,或 nullptr(表最近一輪摸了牌)
	Player();                           // 構造器,該類的實例僅可被 UNO 運行時類創建
}; // Player Class
// Player.cpp

#include <Player.h>

// 構造器,該類的實例僅可被 UNO 運行時類創建
Player::Player() {
	recent = nullptr;
} // Player() (Class Constructor)

// 返回所有手牌
const std::vector<Card*>& Player::getHand() {
	return hand;
} // getHand()

// 返回最近一輪出的牌,或 nullptr 表最近一輪摸了牌
Card* Player::getRecent() {
	return recent;
} // getRecent()

同樣,該類對兩個字段 hand、recent 也做了一層只讀封裝處理,在類內部以及 UNO 運行時類裏,這兩個字段是可改寫的(用於實時更新玩家的狀態);在其他地方看來,這兩個字段是隻讀的(防止玩家的狀態被非法篡改)。另外注意,getHand() 方法返回的是相應字段的常引用,避免了不必要的複製所帶來的開銷

4.3 Uno 運行時類——Uno class

重頭戲來了。這個類要做的事很多,遊戲裏的一切動作,不論是加載卡牌資源,還是洗牌、發牌、摸牌、出牌,都是委託這個類完成的。這裏先簡要說明一下該類由哪些內容構成,然後再上代碼。

類成員:

  • cv::Mat backImage:牌背圖案。
  • cv::Mat wildImage[5]:無色的、指定紅色的、指定藍色的、指定綠色的、指定黃色的變色牌圖案。
  • cv::Mat wd4Image[5]:無色的、指定紅色的、指定藍色的、指定綠色的、指定黃色的變色牌圖案。
  • int now:指示當前是誰的回合。合法值只能是 Player::YOU, Player::COM1, Player::COM2, Player::COM3 之一。
  • int direction:指示玩家的行動順序是順時針還是逆時針。其中 1 表示順時針,3 表示逆時針,相當於每次順時針跳 3 個人。
  • Player player[4]:玩家。存儲並實時更新四個玩家的狀態信息。
  • std::vector<Card> table:牌庫。一個包含了 108 個 Card 對象的列表。
  • std::vector<Card*> deck:發牌堆。遊戲開局時,以隨機順序獲得牌庫(table)列表中所有 Card 對象的指針,生成發牌堆列表;玩家抓牌時,從發牌堆頂端取一個 Card* 對象加入到相應的 player 對象的手牌(hand)列表中。
  • std::vector<Card*> recent:展示最近打出過的牌,最多 5 張。玩家出牌時,將玩家手牌(hand)中相應的 Card* 對象移動到本列表中。當本列表超過 5 個元素時,將本列表頭部的元素移動到下面說的【棄牌堆】列表中。
  • std::vector<Card*> used:棄牌堆。最近出牌列表超過 5 個元素時,將其頭部的元素移動到本列表中,不再向玩家展示。當發牌堆沒有餘牌時,將本列表裏的所有牌以亂序移回發牌堆(洗牌)使遊戲正常進行下去,直到分出勝負爲止。

類函數:

  • Uno():構造器,用於初始化運行時類。
  • static Uno* getInstance():爲減少資源浪費,該類被設計爲單例模式。該函數爲獲取運行時實例的唯一入口。
  • const cv::Mat& getBackImage():獲取牌背圖案。
  • const cv::Mat& getColoredWildImage(Color color):獲取指定過顏色的變色牌圖案。
  • const cv::Mat& getColoredWildDraw4Image(Color color):獲取指定過顏色的變色 +4 牌圖案。
  • int getNow():獲取當前回合的玩家 ID。
  • int switchNow():切換到下一個玩家的回合,並返回其 ID。
  • int getNext():獲取下家的 ID。
  • int getOppo():獲取對家的 ID。
  • int getPrev():獲取上家的 ID。
  • int getDirection():獲取當前行動順序,1 爲順時針,3 爲逆時針。
  • int switchDirection():反轉行動順序,並返回反轉後的行動順序。
  • Player* getPlayer(int id):獲取給定 ID 所對應的玩家對象。
  • int getDeckCount():獲取發牌堆剩餘牌數量。
  • int getUsedCount():獲取棄牌堆剩餘牌數量。
  • const std::vector<Card*>& getRecent():獲取最近出過的牌。
  • void start():開局。
  • Card* draw(int who):指定 ID 的玩家從發牌堆抓一張牌。
  • bool isLegalToPlay(Card* card):指定的卡牌在當前遊戲場景下能否合法打出。
  • Card* play(int who, int index, Color color = NONE):指定 ID 的玩家打出手中指定位置的手牌。其中第三個參數是一個可選參數,僅當打出的牌是黑牌時才需要指定這個參數,用於告知玩家用黑牌指定的接下來的合法顏色是哪個。

對該類的聲明和定義分別放在 include\Uno.h 頭文件和 src\Uno.cpp 實現文件裏。

// Uno.h

#pragma once

#include <vector>
#include <Card.h>
#include <Color.h>
#include <Player.h>
#include <Content.h>
#include <opencv2/core.hpp>

class Uno {
public:
	static const int DIR_LEFT = 1;
	static const int DIR_RIGHT = 3;
	static const int MAX_HOLD_CARDS = 14; // 因畫面大小限制,每位玩家最多持有 14 張手牌
	static Uno* getInstance();
	const cv::Mat& getBackImage();
	const cv::Mat& getColoredWildImage(Color color);
	const cv::Mat& getColoredWildDraw4Image(Color color);
	int getNow();
	int switchNow();
	int getNext();
	int getOppo();
	int getPrev();
	int getDirection();
	int switchDirection();
	Player* getPlayer(int id);
	int getDeckCount();
	int getUsedCount();
	const std::vector<Card*>& getRecent();
	void start();
	Card* draw(int who);
	bool isLegalToPlay(Card* card);
	Card* play(int who, int index, Color color = NONE);

private:
	cv::Mat backImage;
	cv::Mat wildImage[5];
	cv::Mat wildDraw4Image[5];
	int now;
	int direction;
	Player player[4];
	std::vector<Card> table;
	std::vector<Card*> deck;
	std::vector<Card*> recent;
	std::vector<Card*> used;
	Uno();
}; // Uno Class
// Uno.cpp

#include <Uno.h>
#include <ctime>
#include <cstdlib>
#include <iostream>
#include <opencv2/imgcodecs.hpp>

// 圖片資源缺失時在控制檯中顯示的錯誤信息
static const char* BROKEN_IMAGE_RESOURCES_EXCEPTION =
"One or more image resources are broken. Re-install this application.";

// 單一實例,對外隱藏構造器
Uno::Uno() {
	int i;
	cv::Mat br[54], dk[54]; // 54 種亮色/暗色牌面圖案

	// 讀取牌背圖案
	backImage = cv::imread("resource/back.png");
	if (backImage.empty() ||
		backImage.rows != 181 ||
		backImage.cols != 121) {
		std::cout << BROKEN_IMAGE_RESOURCES_EXCEPTION << std::endl;
		exit(1);
	} // if (backImage.empty() || ...)

	// 讀取牌面圖案
	br[0] = cv::imread("resource/front_r0.png");
	br[1] = cv::imread("resource/front_r1.png");
	br[2] = cv::imread("resource/front_r2.png");
	br[3] = cv::imread("resource/front_r3.png");
	br[4] = cv::imread("resource/front_r4.png");
	br[5] = cv::imread("resource/front_r5.png");
	br[6] = cv::imread("resource/front_r6.png");
	br[7] = cv::imread("resource/front_r7.png");
	br[8] = cv::imread("resource/front_r8.png");
	br[9] = cv::imread("resource/front_r9.png");
	br[10] = cv::imread("resource/front_r+.png");
	br[11] = cv::imread("resource/[email protected]");
	br[12] = cv::imread("resource/front_r$.png");
	br[13] = cv::imread("resource/front_b0.png");
	br[14] = cv::imread("resource/front_b1.png");
	br[15] = cv::imread("resource/front_b2.png");
	br[16] = cv::imread("resource/front_b3.png");
	br[17] = cv::imread("resource/front_b4.png");
	br[18] = cv::imread("resource/front_b5.png");
	br[19] = cv::imread("resource/front_b6.png");
	br[20] = cv::imread("resource/front_b7.png");
	br[21] = cv::imread("resource/front_b8.png");
	br[22] = cv::imread("resource/front_b9.png");
	br[23] = cv::imread("resource/front_b+.png");
	br[24] = cv::imread("resource/[email protected]");
	br[25] = cv::imread("resource/front_b$.png");
	br[26] = cv::imread("resource/front_g0.png");
	br[27] = cv::imread("resource/front_g1.png");
	br[28] = cv::imread("resource/front_g2.png");
	br[29] = cv::imread("resource/front_g3.png");
	br[30] = cv::imread("resource/front_g4.png");
	br[31] = cv::imread("resource/front_g5.png");
	br[32] = cv::imread("resource/front_g6.png");
	br[33] = cv::imread("resource/front_g7.png");
	br[34] = cv::imread("resource/front_g8.png");
	br[35] = cv::imread("resource/front_g9.png");
	br[36] = cv::imread("resource/front_g+.png");
	br[37] = cv::imread("resource/[email protected]");
	br[38] = cv::imread("resource/front_g$.png");
	br[39] = cv::imread("resource/front_y0.png");
	br[40] = cv::imread("resource/front_y1.png");
	br[41] = cv::imread("resource/front_y2.png");
	br[42] = cv::imread("resource/front_y3.png");
	br[43] = cv::imread("resource/front_y4.png");
	br[44] = cv::imread("resource/front_y5.png");
	br[45] = cv::imread("resource/front_y6.png");
	br[46] = cv::imread("resource/front_y7.png");
	br[47] = cv::imread("resource/front_y8.png");
	br[48] = cv::imread("resource/front_y9.png");
	br[49] = cv::imread("resource/front_y+.png");
	br[50] = cv::imread("resource/[email protected]");
	br[51] = cv::imread("resource/front_y$.png");
	br[52] = cv::imread("resource/front_kw.png");
	br[53] = cv::imread("resource/front_kw+.png");
	dk[0] = cv::imread("resource/dark_r0.png");
	dk[1] = cv::imread("resource/dark_r1.png");
	dk[2] = cv::imread("resource/dark_r2.png");
	dk[3] = cv::imread("resource/dark_r3.png");
	dk[4] = cv::imread("resource/dark_r4.png");
	dk[5] = cv::imread("resource/dark_r5.png");
	dk[6] = cv::imread("resource/dark_r6.png");
	dk[7] = cv::imread("resource/dark_r7.png");
	dk[8] = cv::imread("resource/dark_r8.png");
	dk[9] = cv::imread("resource/dark_r9.png");
	dk[10] = cv::imread("resource/dark_r+.png");
	dk[11] = cv::imread("resource/[email protected]");
	dk[12] = cv::imread("resource/dark_r$.png");
	dk[13] = cv::imread("resource/dark_b0.png");
	dk[14] = cv::imread("resource/dark_b1.png");
	dk[15] = cv::imread("resource/dark_b2.png");
	dk[16] = cv::imread("resource/dark_b3.png");
	dk[17] = cv::imread("resource/dark_b4.png");
	dk[18] = cv::imread("resource/dark_b5.png");
	dk[19] = cv::imread("resource/dark_b6.png");
	dk[20] = cv::imread("resource/dark_b7.png");
	dk[21] = cv::imread("resource/dark_b8.png");
	dk[22] = cv::imread("resource/dark_b9.png");
	dk[23] = cv::imread("resource/dark_b+.png");
	dk[24] = cv::imread("resource/[email protected]");
	dk[25] = cv::imread("resource/dark_b$.png");
	dk[26] = cv::imread("resource/dark_g0.png");
	dk[27] = cv::imread("resource/dark_g1.png");
	dk[28] = cv::imread("resource/dark_g2.png");
	dk[29] = cv::imread("resource/dark_g3.png");
	dk[30] = cv::imread("resource/dark_g4.png");
	dk[31] = cv::imread("resource/dark_g5.png");
	dk[32] = cv::imread("resource/dark_g6.png");
	dk[33] = cv::imread("resource/dark_g7.png");
	dk[34] = cv::imread("resource/dark_g8.png");
	dk[35] = cv::imread("resource/dark_g9.png");
	dk[36] = cv::imread("resource/dark_g+.png");
	dk[37] = cv::imread("resource/[email protected]");
	dk[38] = cv::imread("resource/dark_g$.png");
	dk[39] = cv::imread("resource/dark_y0.png");
	dk[40] = cv::imread("resource/dark_y1.png");
	dk[41] = cv::imread("resource/dark_y2.png");
	dk[42] = cv::imread("resource/dark_y3.png");
	dk[43] = cv::imread("resource/dark_y4.png");
	dk[44] = cv::imread("resource/dark_y5.png");
	dk[45] = cv::imread("resource/dark_y6.png");
	dk[46] = cv::imread("resource/dark_y7.png");
	dk[47] = cv::imread("resource/dark_y8.png");
	dk[48] = cv::imread("resource/dark_y9.png");
	dk[49] = cv::imread("resource/dark_y+.png");
	dk[50] = cv::imread("resource/[email protected]");
	dk[51] = cv::imread("resource/dark_y$.png");
	dk[52] = cv::imread("resource/dark_kw.png");
	dk[53] = cv::imread("resource/dark_kw+.png");
	for (i = 0; i < 54; ++i) {
		if (br[i].empty() || br[i].rows != 181 || br[i].cols != 121 ||
			dk[i].empty() || dk[i].rows != 181 || dk[i].cols != 121) {
			std::cout << BROKEN_IMAGE_RESOURCES_EXCEPTION << std::endl;
			exit(1);
		} // if (br[i].empty() || ...)
	} // for (i = 0; i < 54; ++i)

	// 讀取各色的變色 / 變色 +4 牌面圖案
	wildImage[0] = br[52];
	wildImage[1] = cv::imread("resource/front_rw.png");
	wildImage[2] = cv::imread("resource/front_bw.png");
	wildImage[3] = cv::imread("resource/front_gw.png");
	wildImage[4] = cv::imread("resource/front_yw.png");
	wildDraw4Image[0] = br[53];
	wildDraw4Image[1] = cv::imread("resource/front_rw+.png");
	wildDraw4Image[2] = cv::imread("resource/front_bw+.png");
	wildDraw4Image[3] = cv::imread("resource/front_gw+.png");
	wildDraw4Image[4] = cv::imread("resource/front_yw+.png");
	for (i = 1; i < 5; ++i) {
		if (wildImage[i].empty() ||
			wildImage[i].rows != 181 ||
			wildImage[i].cols != 121 ||
			wildDraw4Image[i].empty() ||
			wildDraw4Image[i].rows != 181 ||
			wildDraw4Image[i].cols != 121) {
			std::cout << BROKEN_IMAGE_RESOURCES_EXCEPTION << std::endl;
			exit(1);
		} // if (wildImage[i].empty() || ...)
	} // for (i = 1; i < 5; ++i)

	// 生成牌庫
	table.push_back(Card(br[0], dk[0], RED, NUM0, "Red 0"));
	table.push_back(Card(br[1], dk[1], RED, NUM1, "Red 1"));
	table.push_back(Card(br[2], dk[2], RED, NUM2, "Red 2"));
	table.push_back(Card(br[3], dk[3], RED, NUM3, "Red 3"));
	table.push_back(Card(br[4], dk[4], RED, NUM4, "Red 4"));
	table.push_back(Card(br[5], dk[5], RED, NUM5, "Red 5"));
	table.push_back(Card(br[6], dk[6], RED, NUM6, "Red 6"));
	table.push_back(Card(br[7], dk[7], RED, NUM7, "Red 7"));
	table.push_back(Card(br[8], dk[8], RED, NUM8, "Red 8"));
	table.push_back(Card(br[9], dk[9], RED, NUM9, "Red 9"));
	table.push_back(Card(br[10], dk[10], RED, DRAW2, "Red +2"));
	table.push_back(Card(br[11], dk[11], RED, SKIP, "Red Skip"));
	table.push_back(Card(br[12], dk[12], RED, REV, "Red Reverse"));
	table.push_back(Card(br[13], dk[13], BLUE, NUM0, "Blue 0"));
	table.push_back(Card(br[14], dk[14], BLUE, NUM1, "Blue 1"));
	table.push_back(Card(br[15], dk[15], BLUE, NUM2, "Blue 2"));
	table.push_back(Card(br[16], dk[16], BLUE, NUM3, "Blue 3"));
	table.push_back(Card(br[17], dk[17], BLUE, NUM4, "Blue 4"));
	table.push_back(Card(br[18], dk[18], BLUE, NUM5, "Blue 5"));
	table.push_back(Card(br[19], dk[19], BLUE, NUM6, "Blue 6"));
	table.push_back(Card(br[20], dk[20], BLUE, NUM7, "Blue 7"));
	table.push_back(Card(br[21], dk[21], BLUE, NUM8, "Blue 8"));
	table.push_back(Card(br[22], dk[22], BLUE, NUM9, "Blue 9"));
	table.push_back(Card(br[23], dk[23], BLUE, DRAW2, "Blue +2"));
	table.push_back(Card(br[24], dk[24], BLUE, SKIP, "Blue Skip"));
	table.push_back(Card(br[25], dk[25], BLUE, REV, "Blue Reverse"));
	table.push_back(Card(br[26], dk[26], GREEN, NUM0, "Green 0"));
	table.push_back(Card(br[27], dk[27], GREEN, NUM1, "Green 1"));
	table.push_back(Card(br[28], dk[28], GREEN, NUM2, "Green 2"));
	table.push_back(Card(br[29], dk[29], GREEN, NUM3, "Green 3"));
	table.push_back(Card(br[30], dk[30], GREEN, NUM4, "Green 4"));
	table.push_back(Card(br[31], dk[31], GREEN, NUM5, "Green 5"));
	table.push_back(Card(br[32], dk[32], GREEN, NUM6, "Green 6"));
	table.push_back(Card(br[33], dk[33], GREEN, NUM7, "Green 7"));
	table.push_back(Card(br[34], dk[34], GREEN, NUM8, "Green 8"));
	table.push_back(Card(br[35], dk[35], GREEN, NUM9, "Green 9"));
	table.push_back(Card(br[36], dk[36], GREEN, DRAW2, "Green +2"));
	table.push_back(Card(br[37], dk[37], GREEN, SKIP, "Green Skip"));
	table.push_back(Card(br[38], dk[38], GREEN, REV, "Green Reverse"));
	table.push_back(Card(br[39], dk[39], YELLOW, NUM0, "Yellow 0"));
	table.push_back(Card(br[40], dk[40], YELLOW, NUM1, "Yellow 1"));
	table.push_back(Card(br[41], dk[41], YELLOW, NUM2, "Yellow 2"));
	table.push_back(Card(br[42], dk[42], YELLOW, NUM3, "Yellow 3"));
	table.push_back(Card(br[43], dk[43], YELLOW, NUM4, "Yellow 4"));
	table.push_back(Card(br[44], dk[44], YELLOW, NUM5, "Yellow 5"));
	table.push_back(Card(br[45], dk[45], YELLOW, NUM6, "Yellow 6"));
	table.push_back(Card(br[46], dk[46], YELLOW, NUM7, "Yellow 7"));
	table.push_back(Card(br[47], dk[47], YELLOW, NUM8, "Yellow 8"));
	table.push_back(Card(br[48], dk[48], YELLOW, NUM9, "Yellow 9"));
	table.push_back(Card(br[49], dk[49], YELLOW, DRAW2, "Yellow +2"));
	table.push_back(Card(br[50], dk[50], YELLOW, SKIP, "Yellow Skip"));
	table.push_back(Card(br[51], dk[51], YELLOW, REV, "Yellow Reverse"));
	table.push_back(Card(br[52], dk[52], NONE, WILD, "Wild"));
	table.push_back(Card(br[52], dk[52], NONE, WILD, "Wild"));
	table.push_back(Card(br[53], dk[53], NONE, WILD_DRAW4, "Wild +4"));
	table.push_back(Card(br[1], dk[1], RED, NUM1, "Red 1"));
	table.push_back(Card(br[2], dk[2], RED, NUM2, "Red 2"));
	table.push_back(Card(br[3], dk[3], RED, NUM3, "Red 3"));
	table.push_back(Card(br[4], dk[4], RED, NUM4, "Red 4"));
	table.push_back(Card(br[5], dk[5], RED, NUM5, "Red 5"));
	table.push_back(Card(br[6], dk[6], RED, NUM6, "Red 6"));
	table.push_back(Card(br[7], dk[7], RED, NUM7, "Red 7"));
	table.push_back(Card(br[8], dk[8], RED, NUM8, "Red 8"));
	table.push_back(Card(br[9], dk[9], RED, NUM9, "Red 9"));
	table.push_back(Card(br[10], dk[10], RED, DRAW2, "Red +2"));
	table.push_back(Card(br[11], dk[11], RED, SKIP, "Red Skip"));
	table.push_back(Card(br[12], dk[12], RED, REV, "Red Reverse"));
	table.push_back(Card(br[53], dk[53], NONE, WILD_DRAW4, "Wild +4"));
	table.push_back(Card(br[14], dk[14], BLUE, NUM1, "Blue 1"));
	table.push_back(Card(br[15], dk[15], BLUE, NUM2, "Blue 2"));
	table.push_back(Card(br[16], dk[16], BLUE, NUM3, "Blue 3"));
	table.push_back(Card(br[17], dk[17], BLUE, NUM4, "Blue 4"));
	table.push_back(Card(br[18], dk[18], BLUE, NUM5, "Blue 5"));
	table.push_back(Card(br[19], dk[19], BLUE, NUM6, "Blue 6"));
	table.push_back(Card(br[20], dk[20], BLUE, NUM7, "Blue 7"));
	table.push_back(Card(br[21], dk[21], BLUE, NUM8, "Blue 8"));
	table.push_back(Card(br[22], dk[22], BLUE, NUM9, "Blue 9"));
	table.push_back(Card(br[23], dk[23], BLUE, DRAW2, "Blue +2"));
	table.push_back(Card(br[24], dk[24], BLUE, SKIP, "Blue Skip"));
	table.push_back(Card(br[25], dk[25], BLUE, REV, "Blue Reverse"));
	table.push_back(Card(br[53], dk[53], NONE, WILD_DRAW4, "Wild +4"));
	table.push_back(Card(br[27], dk[27], GREEN, NUM1, "Green 1"));
	table.push_back(Card(br[28], dk[28], GREEN, NUM2, "Green 2"));
	table.push_back(Card(br[29], dk[29], GREEN, NUM3, "Green 3"));
	table.push_back(Card(br[30], dk[30], GREEN, NUM4, "Green 4"));
	table.push_back(Card(br[31], dk[31], GREEN, NUM5, "Green 5"));
	table.push_back(Card(br[32], dk[32], GREEN, NUM6, "Green 6"));
	table.push_back(Card(br[33], dk[33], GREEN, NUM7, "Green 7"));
	table.push_back(Card(br[34], dk[34], GREEN, NUM8, "Green 8"));
	table.push_back(Card(br[35], dk[35], GREEN, NUM9, "Green 9"));
	table.push_back(Card(br[36], dk[36], GREEN, DRAW2, "Green +2"));
	table.push_back(Card(br[37], dk[37], GREEN, SKIP, "Green Skip"));
	table.push_back(Card(br[38], dk[38], GREEN, REV, "Green Reverse"));
	table.push_back(Card(br[53], dk[53], NONE, WILD_DRAW4, "Wild +4"));
	table.push_back(Card(br[40], dk[40], YELLOW, NUM1, "Yellow 1"));
	table.push_back(Card(br[41], dk[41], YELLOW, NUM2, "Yellow 2"));
	table.push_back(Card(br[42], dk[42], YELLOW, NUM3, "Yellow 3"));
	table.push_back(Card(br[43], dk[43], YELLOW, NUM4, "Yellow 4"));
	table.push_back(Card(br[44], dk[44], YELLOW, NUM5, "Yellow 5"));
	table.push_back(Card(br[45], dk[45], YELLOW, NUM6, "Yellow 6"));
	table.push_back(Card(br[46], dk[46], YELLOW, NUM7, "Yellow 7"));
	table.push_back(Card(br[47], dk[47], YELLOW, NUM8, "Yellow 8"));
	table.push_back(Card(br[48], dk[48], YELLOW, NUM9, "Yellow 9"));
	table.push_back(Card(br[49], dk[49], YELLOW, DRAW2, "Yellow +2"));
	table.push_back(Card(br[50], dk[50], YELLOW, SKIP, "Yellow Skip"));
	table.push_back(Card(br[51], dk[51], YELLOW, REV, "Yellow Reverse"));
	table.push_back(Card(br[52], dk[52], NONE, WILD, "Wild"));
	table.push_back(Card(br[52], dk[52], NONE, WILD, "Wild"));

	// 初始化其他成員
	now = Player::YOU;
	direction = DIR_LEFT;

	// 以當前時間戳作爲隨機數種子
	srand(unsigned(time(0)));
} // Uno() (Class Constructor)

// 獲取單例
Uno* Uno::getInstance() {
	static Uno instance;
	return &instance;
} // getInstance()

// 獲取牌背圖案
const cv::Mat& Uno::getBackImage() {
	return backImage;
} // getBackImage()

// 獲取指定過顏色的變色牌圖案
const cv::Mat& Uno::getColoredWildImage(Color color) {
	return wildImage[color];
} // getColoredWildImage()

// 獲取指定過顏色的變色 +4 牌圖案
const cv::Mat& Uno::getColoredWildDraw4Image(Color color) {
	return wildDraw4Image[color];
} // getColoredWildDraw4Image()

// 獲取當前回合的玩家 ID
int Uno::getNow() {
	return now;
} // getNow()

// 切換到下一個玩家的回合,並返回其 ID
int Uno::switchNow() {
	now = getNext();
	return now;
} // switchNow()

// 獲取下家 ID
int Uno::getNext() {
	return (now + direction) % 4;
} // getNext()

// 獲取對家 ID
int Uno::getOppo() {
	return (now + direction + direction) % 4;
} // getOppo()

// 獲取上家 ID
int Uno::getPrev() {
	return (4 + now - direction) % 4;
} // getPrev()

// 獲取當前行動順序,DIR_LEFT 爲順時針,DIR_RIGHT 爲逆時針
int Uno::getDirection() {
	return direction;
} // getDirection()

// 反轉行動順序,並返回反轉後的行動順序
int Uno::switchDirection() {
	direction = 4 - direction;
	return direction;
} // switchDirection()

// 獲取給定 ID 所對應的玩家對象
Player* Uno::getPlayer(int id) {
	return (id < Player::YOU || id > Player::COM3) ? nullptr : &player[id];
} // getPlayer()

// 獲取發牌堆剩餘牌數量
int Uno::getDeckCount() {
	return int(deck.size());
} // getDeckCount()

// 獲取棄牌堆剩餘牌數量
int Uno::getUsedCount() {
	return int(used.size());
} // getUsedCount()

// 獲取最近出過的牌
const std::vector<Card*>& Uno::getRecent() {
	return recent;
} // getRecent()

// 開局
void Uno::start() {
	Card* card;
	int i, index, size;
	std::vector<Card*> allCards;
	std::vector<Card>::iterator it;

	// 重置行動順序
	direction = DIR_LEFT;

	// 清除發牌堆、棄牌堆、最近用牌,以及每位玩家的手牌
	deck.clear();
	used.clear();
	recent.clear();
	for (i = Player::YOU; i <= Player::COM3; ++i) {
		player[i].hand.clear();
	} // for (i = Player::YOU; i <= Player::COM3; ++i)

	// 複製牌庫中的所有卡牌引用到一個臨時列表中
	for (it = table.begin(); it != table.end(); ++it) {
		if (it->isWild()) {
			// 重置牌庫中所有黑牌的顏色爲無色
			it->color = NONE;
		} // if (it->isWild())

		allCards.push_back(&(*it));
	} // for (it = table.begin(); it != table.end(); ++it)

	// 從臨時列表中不斷抽取元素放入發牌堆中,直到所有元素都被抽取完畢(模擬洗牌)
	size = int(allCards.size());
	while (size > 0) {
		index = rand() % size;
		deck.push_back(allCards.at(index));
		allCards.erase(allCards.begin() + index);
		--size;
	} // while (size > 0)

	// 每人抓 7 張牌
	for (i = 0; i < 7; ++i) {
		draw(Player::YOU);
		draw(Player::COM1);
		draw(Player::COM2);
		draw(Player::COM3);
	} // for (i = 0; i < 7; ++i)

	// 決定起始牌
	do {
		card = deck.back();
		deck.pop_back();
		if (card->isWild()) {
			// 起始牌不能爲黑牌,抽到黑牌時放入牌堆底部
			// 此時最近出牌列表還爲空,仍要重新抽取起始牌
			deck.insert(deck.begin(), card);
		} // if (card->isWild())
		else {
			// 抽到非黑牌時,以此作爲起始牌,放入最近出牌列表中
			// 然後第一位玩家依據該起始牌來跟牌
			recent.push_back(card);
		} // else
	} while (recent.empty());
} // start()

// 指定玩家抓 1 張牌。
// 注意:每位玩家最多持有 14 張牌,所以即便調用了此函數,玩家也可能實際並未抓牌。
// 如果玩家抓牌了,則返回抓到的牌,否則返回 nullptr。
Card* Uno::draw(int who) {
	Card* card;
	Card* picked;
	int index, size;
	std::vector<Card*>* hand;
	std::vector<Card*>::iterator i;

	card = nullptr;
	if (who >= Player::YOU && who <= Player::COM3) {
		hand = &(player[who].hand);
		if (hand->size() < MAX_HOLD_CARDS) {
			// 從牌堆抓一張牌,並放置到合適的位置
			card = deck.back();
			deck.pop_back();
			for (i = hand->begin(); i != hand->end(); ++i) {
				if ((*i)->order > card->order) {
					// 找到了一個合適的位置放入新卡牌,使得手牌保持有序
					break;
				} // if ((*i)->order > card->order)
			} // for (i = hand->begin(); i != hand->end(); ++i)

			hand->insert(i, card);
			player[who].recent = nullptr;
			if (deck.empty()) {
				// 當發牌堆沒有餘牌時,將棄牌堆的牌打亂順序重新放回發牌堆
				size = int(used.size());
				while (size > 0) {
					index = rand() % size;
					picked = used.at(index);
					if (picked->isWild()) {
						// 重置所有黑牌的顏色爲無色
						picked->color = NONE;
					} // if (picked->isWild())

					deck.push_back(picked);
					used.erase(used.begin() + index);
					--size;
				} // while (size > 0)
			} // if (deck.empty())
		} // if (hand->size() < MAX_HOLD_CARDS)
	} // if (who >= Player::YOU && who <= Player::COM3)

	return card;
} // draw()

// 判斷指定的牌能否在當前情景下合法打出。
// 僅當該牌爲黑牌,或與上一張打出的牌同色/同內容時才能合法打出。
bool Uno::isLegalToPlay(Card* card) {
	bool result;
	Card* previous;

	if (card == nullptr || recent.empty()) {
		// 傳入空指針
		result = false;
	} // if (card == nullptr || recent.empty())
	else if (card->isWild()) {
		// 黑牌:合法
		result = true;
	} // else if (card->isWild())
	else {
		// 與前一張打出的牌同色:合法
		// 與前一張打出的牌同內容:合法
		// 其他牌:不合法
		previous = recent.back();
		result = card->color == previous->color
			|| card->content == previous->content;
	} // else

	return result;
} // isLegalToPlay()

// 指定 ID 的玩家(who)打出手中指定位置(index)的手牌。
// 第三個參數僅當打出的牌爲黑牌時才生效,用於告知該玩家指定的接下來的合法色是什麼。
// 調用本函數前請先調用 isLegalToPlay() 驗證合法性,本函數不對合法性做任何校驗。
// 返回打出的牌的引用。如果傳入了無效的 who 或 index,則返回 nullptr。
Card* Uno::play(int who, int index, Color color) {
	Card* card;
	std::vector<Card*>* hand;

	card = nullptr;
	if (who >= Player::YOU && who <= Player::COM3) {
		hand = &(player[who].hand);
		if (index < hand->size()) {
			card = hand->at(index);
			hand->erase(hand->begin() + index);
			if (card->isWild()) {
				// 打出黑牌時,將該黑牌的顏色由無色切換爲該玩家指定的顏色
				card->color = color;
			} // if (card->isWild())

			// 將該牌添加到最近出牌列表裏
			player[who].recent = card;
			recent.push_back(card);
			if (recent.size() > 5) {
				// 如果最近出牌超過 5 張,則將該列表的頭部元素移動到棄牌堆
				used.push_back(recent.front());
				recent.erase(recent.begin());
			} // if (recent.size() > 5)
		} // if (index < hand->size())
	} // if (who >= Player::YOU && who <= Player::COM3)

	return card;
} // play()

4.4 AI 類——AI class

AI 要做的事情很簡單,就是判斷在自己的回合裏應該出哪一張牌。它只需要告訴調用者,當前玩家要出的牌的所在位置就行了,至於後續的出牌動作等等,並不由該類負責。那麼我們只要設計一個返回值爲 int 的函數,返回當前玩家的最佳出牌在手牌中的什麼位置就行了。同時,如果沒有合適的牌可以出,就返回一個負數,告訴調用者,本輪摸一張牌。

對該類的聲明和定義分別放在 include\AI.h 頭文件和 src\AI.cpp 實現文件裏。

// AI.h

#pragma once

#include <Uno.h>
#include <Card.h>
#include <Color.h>

class AI {
public:
	AI();                                           // 構造器
	int bestCardIndex4NowPlayer(Color outColor[1]); // 返回當前玩家的最佳出牌

private:
	Uno* uno;                                       // 運行時單例
	Color calcBestColor4NowPlayer();                // 計算當前玩家的最強色
}; // AI Class

// AI.cpp

#include <AI.h>
#include <vector>
#include <Player.h>
#include <Content.h>

// 構造器,初始化運行時引用
AI::AI() {
	uno = Uno::getInstance();
} // AI() (Class Constructor)

// 返回當前玩家的最佳出牌在手裏的位置。若返回負數則表示玩家需要摸一張牌。
// 第二個參數是一個輸出參數,該參數僅當要出的牌爲黑牌時才生效,用於指示當前
// 玩家打出該黑牌後應當指定的顏色。執行本函數後 outColor[0] 的值將被改寫。
int AI::bestCardIndex4NowPlayer(Color outColor[1]) {
	int i;
	Card* card;
	Card* last;
	Player* curr;
	Player* next;
	Player* prev;
	std::vector<Card*> hand;
	Color bestColor, lastColor;
	int yourSize, nextSize, prevSize;
	bool hasNum, hasRev, hasSkip, hasDraw2, hasWild, hasWD4;
	int idxBest, idxNum, idxRev, idxSkip, idxDraw2, idxWild, idxWD4;

	if (outColor == nullptr) {
		throw "outColor[] cannot be nullptr";
	} // if (outColor == nullptr)

	curr = uno->getPlayer(uno->getNow());
	hand = curr->getHand();
	yourSize = int(hand.size());
	if (yourSize == 1) {
		// 手中僅剩餘一張牌,能出則出,否則摸牌,無需多慮。
		return uno->isLegalToPlay(hand.at(0)) ? 0 : -1;
	} // if (yourSize == 1)

	next = uno->getPlayer(uno->getNext());
	nextSize = int(next->getHand().size());
	prev = uno->getPlayer(uno->getPrev());
	prevSize = int(prev->getHand().size());
	hasNum = hasRev = hasSkip = hasDraw2 = hasWild = hasWD4 = false;
	idxBest = idxNum = idxRev = idxSkip = idxDraw2 = idxWild = idxWD4 = -1;
	bestColor = calcBestColor4NowPlayer();
	last = uno->getRecent().back();
	lastColor = last->getColor();
	for (i = 0; i < yourSize; ++i) {
		// 遍歷手牌列表,找出所有合法的手牌
		card = hand.at(i);
		if (uno->isLegalToPlay(card)) {
			switch (card->content) {
			case DRAW2:
				if (!hasDraw2 || card->getColor() == bestColor) {
					// 當有多張 +2 能出時,優先指定自己最強色上的 +2。
					// 其他類型的牌同理,有多張能出時,優先往自己的最強色上指。
					idxDraw2 = i;
					hasDraw2 = true;
				} // if (!hasDraw2 || card->getColor() == bestColor)
				break; // case DRAW2

			case SKIP:
				if (!hasSkip || card->getColor() == bestColor) {
					idxSkip = i;
					hasSkip = true;
				} // if (!hasSkip || card->getColor() == bestColor)
				break; // case SKIP

			case REV:
				if (!hasRev || card->getColor() == bestColor) {
					idxRev = i;
					hasRev = true;
				} // if (!hasRev || card->getColor() == bestColor)
				break; // case REV

			case WILD:
				idxWild = i;
				hasWild = true;
				break; // case WILD

			case WILD_DRAW4:
				idxWD4 = i;
				hasWD4 = true;
				break; // case WILD_DRAW4

			default: // 數字牌
				if (!hasNum || card->getColor() == bestColor) {
					idxNum = i;
					hasNum = true;
				} // if (!hasNum || card->getColor() == bestColor)
				break; // default
			} // switch (card->content)
		} // if (uno->isLegalToPlay(card))
	} // for (i = 0; i < yourSize; ++i)

	// 決策樹
	if (nextSize == 1) {
		// 下家只剩一張牌時,優先出功能牌控制下家行動,如 +2、禁止等
		if (hasDraw2) {
			idxBest = idxDraw2;
		} // if (hasDraw2)
		else if (hasSkip) {
			idxBest = idxSkip;
		} // else if (hasSkip)
		else if (hasRev) {
			idxBest = idxRev;
		} // else if (hasRev)
		else if (hasWD4) {
			idxBest = idxWD4;
		} // else if (hasWD4)
		else if (hasWild && lastColor != bestColor) {
			idxBest = idxWild;
		} // else if (hasWild && lastColor != bestColor)
		else if (hasNum) {
			idxBest = idxNum;
		} // else if (hasNum)
	} // if (nextSize == 1)
	else {
		// 通常情況,優先出數字牌,保留功能牌備用。另外儘量使用
		// 反轉牌確保牌少的一方做自己上家,以使自己受到最少的控制。
		if (hasRev && prevSize > nextSize) {
			idxBest = idxRev;
		} // if (hasRev && prevSize > nextSize)
		else if (hasNum) {
			idxBest = idxNum;
		} // else if (hasNum)
		else if (hasSkip) {
			idxBest = idxSkip;
		} // else if (hasSkip)
		else if (hasDraw2) {
			idxBest = idxDraw2;
		} // else if (hasDraw2)
		else if (hasRev && prevSize >= 4) {
			idxBest = idxRev;
		} // else if (hasRev && prevSize >= 4)
		else if (hasWild) {
			idxBest = idxWild;
		} // else if (hasWild)
		else if (hasWD4) {
			idxBest = idxWD4;
		} // else if (hasWD4)
	} // else

	outColor[0] = bestColor;
	return idxBest;
} // bestCardIndex4NowPlayer()

// 計算當前玩家的最強色
Color AI::calcBestColor4NowPlayer() {
	Color best = RED;
	int score[5] = { 0, 0, 0, 0, 0 };

	for (Card* card : uno->getPlayer(uno->getNow())->getHand()) {
		switch (card->content) {
		case REV:
		case NUM0:
			// 反轉牌及 0 牌計 2 分
			score[card->getColor()] += 2;
			break; // case REV, NUM0

		case SKIP:
		case DRAW2:
			// 禁止牌及 +2 牌計 5 分
			score[card->getColor()] += 5;
			break; // case SKIP, DRAW2

		default:
			// 非 0 數字牌計 4 分
			score[card->getColor()] += 4;
			break; // default
		} // switch (card->content)
	} // for (Card* card : uno->getPlayer(uno->getNow)->getHand())

	// 計算擁有最高分值的顏色,作爲當前玩家的最強色
	// 默認爲紅色,當玩家僅剩黑牌在手時,本函數將返回紅色
	if (score[BLUE] > score[best]) {
		best = BLUE;
	} // if (score[BLUE] > score[best]

	if (score[GREEN] > score[best]) {
		best = GREEN;
	} // if (score[GREEN] > score[best])

	if (score[YELLOW] > score[best]) {
		best = YELLOW;
	} // if (score[YELLOW] > score[best])

	return best;
} // calcBestColor4NowPlayer()

到此爲止,我們 UNO 遊戲的核心模塊就完成了。下面就要開始編寫人機交互的主程序部分了。這裏請確認你的 Visual Studio 右側的解決方案管理器是這樣子的:

4.5 主程序

主程序負責兩部分任務:屏幕顯示、遊戲流程控制。這兩部分任務相輔相成,彼此依賴。遊戲在不同的流程裏會顯示出不同的屏幕畫面,同時用戶在屏幕畫面的不同位置點擊也會影響遊戲流程。

4.5.1 遊戲流程

我們先說遊戲流程。大致的遊戲流程就像下面這個樣子:

 

  歡迎界面

    ↓

 ↗開局發牌

|   ↓          / 等待玩家的行動

|       / 玩家回合 ― 選擇黑牌後指定顏色

| 遊戲進程         \ 摸牌或出牌

|       \ 電腦回合

|   ↓

 \遊戲結束

    ↓

  退出遊戲

 

我們用一個全局變量 sStatus 來指示遊戲的這麼多種狀態,並設置一個統一的回調函數 onStatusChanged 來統一處理狀態值改變後的操作。

// main.cpp

// 頭文件包含區
#include <AI.h>
#include <Uno.h>
#include <Card.h>
#include <cstdlib>
#include <sstream>
#include <Color.h>
#include <Player.h>
#include <Content.h>
#include <opencv2/highgui.hpp>
#include <opencv2/imgproc.hpp>

// 常量區
static const int STAT_IDLE = 0x1111;           // 空閒狀態,不響應鼠標點擊事件
static const int STAT_WELCOME = 0x2222;        // 歡迎畫面
static const int STAT_NEW_GAME = 0x3333;       // 開局
static const int STAT_GAME_OVER = 0x4444;      // 遊戲結束
static const int STAT_WILD_COLOR = 0x5555;     // 玩家出黑牌前的指定顏色過程

// 全局變量區
static Uno* sUno;                              // 運行時單例
static int sStatus;                            // 實時狀態碼

// 函數聲明區
static void onStatusChanged(int);              // 回調函數,狀態碼改變時觸發

// 入口點
int main() {
	sUno = Uno::getInstance();

	// TODO: 等待實現
} // main()

// 回調函數,狀態碼改變時觸發
static void onStatusChanged(int status) {
	// TODO: 等待實現
} // onStatusChanged()

我們將剛纔定義的狀態碼附加到流程圖的後面,流程圖就變成了下面的樣子:

 

  歡迎界面(STAT_WELCOME)

    ↓

 ↗開局發牌(STAT_NEW_GAME)

|   ↓          / 等待玩家行動(Player::YOU)

|       / 玩家回合 ― 選擇黑牌後指定顏色(STAT_WILD_COLOR)

| 遊戲進程         \ 摸牌或出牌(STAT_IDLE)

|       \ 電腦回合(Player::COM1, Player::COM2, Player::COM3)

|   ↓

 \遊戲結束(STAT_GAME_OVER)

    ↓

  退出遊戲(程序終止)

 

可以看出來,主程序通過用戶在屏幕畫面上的點擊來相應地改變 sStatus 全局變量的值,然後觸發回調函數 onStatusChanged 來控制遊戲流程。同時,屏幕畫面也需要根據實時狀態碼的值來相應地繪製,歡迎畫面和遊戲進程畫面肯定不是一樣的。那我們現在首先來繪製最簡單的歡迎畫面。

4.5.2 歡迎畫面

這裏我們要繪製的歡迎畫面,只有位於畫面中央的一個按鈕(其實是一張牌背面的圖案),和一串文字“點擊按鈕開始遊戲”。相關代碼如下:

// main.cpp

// 頭文件包含區:略

// 常量區
// <已略去已定義過的內容,以下爲新增內容...>
static const cv::Scalar RGB_RED = CV_RGB(0xFF, 0x55, 0x55);        // 紅色
static const cv::Scalar RGB_BLUE = CV_RGB(0x55, 0x55, 0xFF);       // 藍色
static const cv::Scalar RGB_GREEN = CV_RGB(0x55, 0xAA, 0x55);      // 綠色
static const cv::Scalar RGB_WHITE = CV_RGB(0xCC, 0xCC, 0xCC);      // 白色
static const cv::Scalar RGB_YELLOW = CV_RGB(0xFF, 0xAA, 0x11);     // 黃色
static const enum cv::HersheyFonts SANS = cv::FONT_HERSHEY_DUPLEX; // 字體

// 全局變量區
// <已略去已定義過的內容,以下爲新增內容...>
static cv::Mat sBlank;                         // 空白畫面,擦除用
static cv::Mat sScreen;                        // 實時畫面

// 函數聲明區
// <已略去已定義過的內容,以下爲新增內容...>
static void refreshScreen(const std::string&); // 刷新屏幕畫面
static void onMouse(int, int, int, int, void*);// 回調函數,遊戲窗口中發生鼠標事件時觸發

// 入口點
int main() {
	sUno = Uno::getInstance();
	sBlank = cv::Mat::zeros(720, 1280, CV_8UC3);
	sScreen = sBlank.clone();
	sStatus = STAT_WELCOME;
	cv::namedWindow("Uno");
	onStatusChanged(sStatus);
	cv::setMouseCallback("Uno", onMouse, nullptr);
	for (;;) {
		if (cv::waitKey(0) == 0x1b) break; // 等待用戶按下 ESC 鍵後結束程序
	} // for (;;)

	cv::destroyAllWindows();
	return 0;
} // main()

// 回調函數,狀態碼改變時觸發
static void onStatusChanged(int status) {
	cv::Rect rect;
	cv::Size axes;
	cv::Point center;

	switch (status) {
	case STAT_WELCOME:
		refreshScreen("WELCOME TO UNO CARD GAME, CLICK UNO TO START");
		break; // case STAT_WELCOME

	case STAT_NEW_GAME:
		// TODO: 開啓新遊戲時的操作
		break; // case STAT_NEW_GAME

	case Player::YOU:
		// TODO: 進入玩家回合時的操作
		break; // case Player::YOU

	case STAT_WILD_COLOR:
		// TODO: 玩家準備打出黑牌時的選色操作
		break; // case STAT_WILD_COLOR

	case Player::COM1:
	case Player::COM2:
	case Player::COM3:
		// TODO: 進入電腦回合時的操作
		break; // case Player::COM1, Player::COM2, Player::COM3

	case STAT_GAME_OVER:
		// TODO: 遊戲結束時的操作
		break; // case STAT_GAME_OVER

	default:
		break; // default
	} // switch (status)
} // onStatusChanged()

// 刷新屏幕畫面
static void refreshScreen(const std::string& message) {
	cv::Rect roi;
	cv::Mat image;
	cv::Point point;
	std::vector<Card*> hand;
	int i, remain, used, rate;
	int status, size, width, height;

	// 將當前狀態碼緩存到臨時變量中,避免執行過程中狀態碼改變導致的二義性
	status = sStatus;

	// 清屏
	sBlank.copyTo(sScreen);

	// 在屏幕中間偏下一點的位置顯示傳入的消息文字
	width = cv::getTextSize(message, SANS, 1.0, 1, nullptr).width;
	point = cv::Point(640 - width / 2, 480);
	cv::putText(sScreen, message, point, SANS, 1.0, RGB_WHITE);

	// 當狀態碼爲 STAT_WELCOME 時,繪製歡迎界面
	if (status == STAT_WELCOME) {
		image = sUno->getBackImage();       // 牌背圖案
		roi = cv::Rect(580, 270, 121, 181); // 感興趣區域
		image.copyTo(sScreen(roi));         // 將牌背圖案覆蓋到畫面的感興趣區域
	} // if (status == STAT_WELCOME)
	else {
		// TODO: 當狀態碼不爲 STAT_WELCOME 時,繪製遊戲界面
	} // else

	// 畫面編輯完畢後,顯示到窗口裏
	cv::imshow("Uno", sScreen);
} // refreshScreen()

// 回調函數,遊戲窗口中發生鼠標事件時觸發
static void onMouse(int event, int x, int y, int flags, void* param) {
	// TODO: 等待實現
} // onMouse()

此時按下 Ctrl+F5 運行程序,如果能看到如下圖所示的畫面,就說明我們的歡迎界面設計成功了。然後按 ESC 鍵結束程序。

你應該注意到了,除了 refreshScreen 這個用於繪製屏幕畫面的函數外,我還定義了一個鼠標回調函數 onMouse。這個函數是一個回調函數,當 main 函數裏執行了 cv::setMouseCallback("Uno", onMouse, nullptr); 這條指令之後,OpenCV 就開始監聽在名爲 Uno 的窗口裏發生的鼠標事件了,並把捕捉到的事件回傳到 onMouse 函數的參數中。在 Uno 窗口裏的鼠標移動、鼠標按下、鼠標擡起事件都在回調函數的監聽範圍內。之後的用戶交互,也是要通過這個函數來實現的。

4.5.3 實現第一個人機交互——開局

現在我們要實現這樣的功能:用戶點擊歡迎界面的 UNO 按鈕時,開啓一局新的 UNO 遊戲,並繪製遊戲的開局畫面。

// main.cpp

// 頭文件包含區:略

// 常量區:略

// 全局變量區:略

// 函數聲明區:略

// 入口點
int main() { /* 略 */ }

// 回調函數,狀態碼改變時觸發
static void onStatusChanged(int status) {
	cv::Rect rect;
	cv::Size axes;
	cv::Point center;

	switch (status) {
	case STAT_WELCOME:
		refreshScreen("WELCOME TO UNO CARD GAME, CLICK UNO TO START");
		break; // case STAT_WELCOME

	case STAT_NEW_GAME:
		sUno->start();
		refreshScreen("GET READY");
		cv::waitKey(2000);
		sStatus = sUno->getNow();
		onStatusChanged(sStatus);
		break; // case STAT_NEW_GAME

	case Player::YOU:
		refreshScreen("Your turn, play or draw a card");
		break; // case Player::YOU

	case STAT_WILD_COLOR:
		// TODO: 玩家準備打出黑牌時的選色操作
		break; // case STAT_WILD_COLOR

	case Player::COM1:
	case Player::COM2:
	case Player::COM3:
		// TODO: 進入電腦回合時的操作
		break; // case Player::COM1, Player::COM2, Player::COM3

	case STAT_GAME_OVER:
		// TODO: 遊戲結束時的操作
		break; // case STAT_GAME_OVER

	default:
		break; // default
	} // switch (status)
} // onStatusChanged()

// 刷新屏幕畫面
static void refreshScreen(const std::string& message) {
	cv::Rect roi;
	cv::Mat image;
	cv::Point point;
	std::vector<Card*> hand;
	int remain, used, status, size, width, height;

	// 將當前狀態碼緩存到臨時變量中,避免執行過程中狀態碼改變導致的二義性
	status = sStatus;

	// 清屏
	sBlank.copyTo(sScreen);

	// 在屏幕中間偏下一點的位置顯示傳入的消息文字
	width = cv::getTextSize(message, SANS, 1.0, 1, nullptr).width;
	point = cv::Point(640 - width / 2, 480);
	cv::putText(sScreen, message, point, SANS, 1.0, RGB_WHITE);

	// 當狀態碼爲 STAT_WELCOME 時,繪製歡迎界面
	if (status == STAT_WELCOME) {
		image = sUno->getBackImage();       // 牌背圖案
		roi = cv::Rect(580, 270, 121, 181); // 感興趣區域
		image.copyTo(sScreen(roi));         // 將牌背圖案覆蓋到畫面的感興趣區域
	} // if (status == STAT_WELCOME)
	else {
		// 當狀態碼不爲 STAT_WELCOME 時,繪製遊戲界面
		// 屏幕中央:繪製發牌堆和最近出過的牌
		image = sUno->getBackImage();       // 牌背圖案用來代表發牌堆
		roi = cv::Rect(338, 270, 121, 181); // 牌背圖案所在感興趣區域
		image.copyTo(sScreen(roi));         // 在指定區域繪製牌背圖案
		hand = sUno->getRecent();           // 最近出過的牌
		size = int(hand.size());            // 最近出牌列表裏有幾張牌

		// 測量最近出牌寬度,以便橫向居中展示
		// 只有最後一張露在最外側的牌佔滿 121 像素寬,其餘被摺疊的牌只佔 45 像素寬
		// 所以總寬度即爲 45 * (size - 1) + 121 = 45 * size + 76 像素
		width = 45 * size + 76;

		// 設置首張最近出牌的繪製區域
		roi.x = 792 - width / 2;
		roi.y = 270;

		// 遍歷所有最近出牌並依次繪製
		for (Card* recent : hand) {
			// 如果最近出牌裏有黑牌,則需要爲其塗上打出該牌的玩家所指定的顏色
			switch (recent->content) {
			case WILD:
				image = sUno->getColoredWildImage(recent->getColor());
				break; // case WILD

			case WILD_DRAW4:
				image = sUno->getColoredWildDraw4Image(recent->getColor());
				break; // case WILD_DRAW4

			default:
				image = recent->image;
				break; // default
			} // switch (recent->content)

			// 在指定區域繪製相應的最近出牌。
			// 這裏調用的 copyTo 函數的第二個參數是掩碼,掩碼矩陣中
			// 顏色爲 0 的像素不復制。這裏使用卡牌圖像自身做掩碼,
			// 表示卡牌邊緣全黑的像素不復制。
			image.copyTo(sScreen(roi), image);

			// 將感興趣區域整體向右偏移 45 像素,準備繪製下一張最近出牌
			roi.x += 45;
		} // for (Card* recent : hand)

		// 左上角:顯示發牌堆剩餘牌數 / 已用牌數
		point.x = 20;
		point.y = 42;
		remain = sUno->getDeckCount();
		used = int(sUno->getUsedCount() + hand.size());
		cv::putText(
			/* img       */ sScreen,
			/* text      */ std::to_string(remain) + "/" + std::to_string(used),
			/* org       */ point,
			/* fontFace  */ SANS,
			/* fontScale */ 1.0,
			/* color     */ RGB_WHITE
		); // cv::putText()

		// 左側:西家(COM1)手牌
		hand = sUno->getPlayer(Player::COM1)->getHand();
		size = int(hand.size());
		if (size == 0) {
			// 出完了所有手牌,該玩家爲贏家
			point.x = 51;
			point.y = 461;
			cv::putText(sScreen, "WIN", point, SANS, 1.0, RGB_YELLOW);
		} // if (size == 0)
		else {
			// 測量手牌高度,以便縱向居中展示手牌
			// 只有最後一張露在最外側的手牌佔滿 181 像素高,其餘被摺疊的牌只佔 40 像素高
			// 所以總高度即爲 40 * (size - 1) + 181 = 40 * size + 141 像素
			height = 40 * size + 141;

			// 設置首張手牌的繪製區域
			roi.x = 20;
			roi.y = 360 - height / 2;

			// 遍歷所有手牌並依次繪製
			for (Card* card : hand) {
				// 僅當遊戲結束時才正面展示手牌,遊戲進程中只展示牌背
				if (status == STAT_GAME_OVER) {
					image = card->image;
				} // if (status == STAT_GAME_OVER)
				else {
					image = sUno->getBackImage();
				} // else

				// 繪製當前手牌,並將感興趣區域整體向下偏移 40 像素,準備繪製下一張手牌
				image.copyTo(sScreen(roi), image);
				roi.y += 40;
			} // for (Card* card : hand)

			if (size == 1) {
				// 如果該玩家只有一張手牌,則在手牌旁顯示 UNO 報警
				point.x = 47;
				point.y = 494;
				cv::putText(sScreen, "UNO", point, SANS, 1.0, RGB_YELLOW);
			} // if (size == 1)
		} // else

		// 上方:北家(COM2)手牌
		hand = sUno->getPlayer(Player::COM2)->getHand();
		size = int(hand.size());
		if (size == 0) {
			// 出完了所有手牌,該玩家爲贏家
			point.x = 611;
			point.y = 121;
			cv::putText(sScreen, "WIN", point, SANS, 1.0, RGB_YELLOW);
		} // if (size == 0)
		else {
			// 測量手牌寬度,以便橫向居中展示手牌
			// 只有最後一張露在最外側的手牌佔滿 121 像素寬,其餘被摺疊的牌只佔 45 像素寬
			// 所以總寬度即爲 45 * (size - 1) + 121 = 45 * size + 76 像素
			width = 45 * size + 76;

			// 設置首張手牌的繪製區域
			roi.x = 640 - width / 2;
			roi.y = 20;

			// 遍歷所有手牌並依次繪製
			for (Card* card : hand) {
				// 僅當遊戲結束時才正面展示手牌,遊戲進程中只展示牌背
				if (status == STAT_GAME_OVER) {
					image = card->image;
				} // if (status == STAT_GAME_OVER)
				else {
					image = sUno->getBackImage();
				} // else

				// 繪製當前手牌,並將感興趣區域整體向右偏移 45 像素,準備繪製下一張手牌
				image.copyTo(sScreen(roi), image);
				roi.x += 45;
			} // for (Card* card : hand)

			if (size == 1) {
				// 如果該玩家只有一張手牌,則在手牌旁顯示 UNO 報警
				point.x = 500;
				point.y = 121;
				cv::putText(sScreen, "UNO", point, SANS, 1.0, RGB_YELLOW);
			} // if (size == 1)
		} // else

		// 右側:東家(COM3)手牌
		hand = sUno->getPlayer(Player::COM3)->getHand();
		size = int(hand.size());
		if (size == 0) {
			// 出完了所有手牌,該玩家爲贏家
			point.x = 1170;
			point.y = 461;
			cv::putText(sScreen, "WIN", point, SANS, 1.0, RGB_YELLOW);
		} // if (size == 0)
		else {
			// 測量手牌高度,以便縱向居中展示手牌
			// 只有最後一張露在最外側的手牌佔滿 181 像素高,其餘被摺疊的牌只佔 40 像素高
			// 所以總高度即爲 40 * (size - 1) + 181 = 40 * size + 141 像素
			height = 40 * size + 141;

			// 設置首張手牌的繪製區域
			roi.x = 1140;
			roi.y = 360 - height / 2;

			// 遍歷所有手牌並依次繪製
			for (Card* card : hand) {
				// 僅當遊戲結束時才正面展示手牌,遊戲進程中只展示牌背
				if (status == STAT_GAME_OVER) {
					image = card->image;
				} // if (status == STAT_GAME_OVER)
				else {
					image = sUno->getBackImage();
				} // else

				// 繪製當前手牌,並將感興趣區域整體向下偏移 40 像素,準備繪製下一張手牌
				image.copyTo(sScreen(roi), image);
				roi.y += 40;
			} // for (Card* card : hand)

			if (size == 1) {
				// 如果該玩家只有一張手牌,則在手牌旁顯示 UNO 報警
				point.x = 1166;
				point.y = 494;
				cv::putText(sScreen, "UNO", point, SANS, 1.0, RGB_YELLOW);
			} // if (size == 1)
		} // else

		// 底部:玩家的手牌
		hand = sUno->getPlayer(Player::YOU)->getHand();
		size = int(hand.size());
		if (size == 0) {
			// 出完了所有手牌,該玩家爲贏家
			point.x = 611;
			point.y = 621;
			cv::putText(sScreen, "WIN", point, SANS, 1.0, RGB_YELLOW);
		} // if (size == 0)
		else {
			// 測量手牌寬度,以便橫向居中展示手牌
			// 只有最後一張露在最外側的手牌佔滿 121 像素寬,其餘被摺疊的牌只佔 45 像素寬
			// 所以總寬度即爲 45 * (size - 1) + 121 = 45 * size + 76 像素
			width = 45 * size + 76;

			// 設置首張手牌的繪製區域
			roi.x = 640 - width / 2;
			roi.y = 520;

			// 遍歷所有手牌並依次繪製
			for (Card* card : hand) {
				// 非玩家回合時,手牌全部呈淡色顯示
				// 處於玩家回合時,可出的牌呈高亮顯示,其他牌呈淡色顯示
				if (status == Player::YOU && sUno->isLegalToPlay(card)) {
					image = card->image;
				} // if (status == Player::YOU && sUno->isLegalToPlay(card))
				else {
					image = card->darkImg;
				} // else

				  // 繪製當前手牌,並將感興趣區域整體向右偏移 45 像素,準備繪製下一張手牌
				image.copyTo(sScreen(roi), image);
				roi.x += 45;
			} // for (Card* card : hand)

			if (size == 1) {
				// 如果該玩家只有一張手牌,則在手牌旁顯示 UNO 報警
				point.x = 720;
				point.y = 621;
				cv::putText(sScreen, "UNO", point, SANS, 1.0, RGB_YELLOW);
			} // if (size == 1)
		} // else
	} // else

	// 畫面編輯完畢後,顯示到窗口裏
	cv::imshow("Uno", sScreen);
} // refreshScreen()

// 回調函數,遊戲窗口中發生鼠標事件時觸發
static void onMouse(int event, int x, int y, int flags, void* param) {
	if (event == cv::EVENT_LBUTTONDOWN) {
		// 只響應鼠標左鍵按下的事件,其他事件(移動、左鍵擡起、右鍵相關事件)一律不響應
		switch (sStatus) {
		case STAT_WELCOME:
			if (y >= 270 && y <= 450 && x >= 580 && x <= 700) {
				// 鼠標點擊了牌背按鈕,開始一局新的 UNO 遊戲
				sStatus = STAT_NEW_GAME;
				onStatusChanged(sStatus);
			} // if (y >= 270 && y <= 450 && x >= 580 && x <= 700)
			break; // case STAT_WELCOME

		case Player::YOU:
			// TODO: 玩家回合的人機交互
			break; // case Player::YOU

		case STAT_WILD_COLOR:
			// TODO: 玩家準備打出黑牌時的選色人機交互
			break; // case STAT_WILD_COLOR

		case STAT_GAME_OVER:
			// TODO: 遊戲結束後的人機交互
			break; // case STAT_GAME_OVER

		default:
			break; // default
		} // switch (sStatus)
	} // if (event == cv::EVENT_LBUTTONDOWN)
} // onMouse()

然後運行程序,當點擊了屏幕中央的 UNO 時,如果出現了類似下圖這樣的遊戲界面,就說明我們遊戲畫面繪製成功了。我這盤運氣還挺好的,開局四張黑牌的說。

我們先說交互是怎麼實現的,然後再說遊戲界面是怎麼繪製的。現在讓我們把目光聚焦到鼠標事件回調函數 onMouse 中,首先第一句條件語句 if (event == cv::EVENT_LBUTTONDOWN) 是一個事件過濾器,因爲只要是在 Uno 窗口裏發生了鼠標事件,無論是鼠標移動,還是左鍵按下/擡起,還是右鍵按下/擡起,這個函數都會被觸發。加了這樣一個條件過濾器後,我們就只響應鼠標左鍵按下的事件了,其他事件即使捕捉到了也不做任何處理。

然後第二句 switch (sStatus),不同的狀態下,我們對於用戶的點擊需要做不同的處理。同樣一個牌堆按鈕,在歡迎界面上點擊是開局,在玩家的遊戲回合裏點擊是從牌堆抓一張牌,在遊戲結束時點擊是重新開始一局新遊戲。這裏,當 sStatus 的值是 STAT_WELCOME 時,表明我們目前處於歡迎界面。那麼若此時,用戶點擊了鼠標左鍵,同時鼠標箭頭落在 (580, 270) ~ (700, 450) 座標範圍內時(剛好是 UNO 按鈕所在矩形範圍),就將狀態碼 sStatus 切換至 STAT_NEW_GAME,同時調用觸發器函數 onStatusChanged 以通知程序狀態碼發生改變。

這就是交互的本質:用戶在窗口中發生了一個鼠標左鍵點擊事件,onMouse 函數捕捉到該事件,然後根據當前狀態碼 sStatus 和用戶點擊鼠標的位置座標 (x, y) 來判斷這是否是一次有效的交互。若判定交互有效,則相應地修改狀態碼 sStatus 的值,然後調用回調函數 onStatusChanged,就完成了一次交互。流程圖如下所示:

 

發生鼠標點擊事件

  ↓

觸發 onMouse()

  ↓

根據 sStatus 和 (x,y) 的值

判斷是否爲一次有效交互――→(否)不做任何處理

  ↓(是)

修改 sStatus 的值

  ↓

調用 onStatusChanged() 回調函數

 

然後我們來看觸發器函數 onStatusChanged。可以看到,在 status 的值爲 STAT_NEW_GAME 的這一分支裏,就是開局時要做的具體操作。首先是調用 Uno 運行時類裏的 start 函數初始化摸牌堆、棄牌堆、每位玩家的手牌,以及最近出牌列表。然後,調用了 refreshScreen 函數刷新遊戲畫面,再接下來調用了 cv::waitKey 函數等待 2000ms,最後,將狀態碼改爲當前玩家的 ID(默認爲 Player::YOU,如果不是第一局,則爲上一局的贏家的 ID),並調用 onStatusChanged 函數以便相應玩家能夠執行操作。當進入玩家回合時,只執行了一步操作,就是刷新遊戲畫面,電腦不再需要額外的操作了,只需要在 onMouse 函數裏實現和用戶交互相關的操作就可以了。

refreshScreen 這個函數看着很長,實際上很簡單,註釋裏都寫得很詳細了。簡單總結一下:左上角顯示剩餘牌和已用牌,屏幕中央顯示牌堆和最近出牌列表,四條邊分別顯示四位玩家的手牌,其中三位電腦玩家的手牌在遊戲進程中只顯示背面,在遊戲結束時才展示具體手牌內容。在顯示玩家手牌和最近出牌列表時,爲了在有限的窗口大小內多顯示幾張牌,用了摺疊顯示的方案,也就是僅最後一張露在最外側的牌才佔牌自身的寬/高,其他“被疊起來的”牌都只佔部分寬/高。

4.5.4 讓遊戲跑起來——實現玩家的摸牌、出牌動作,以及電腦 AI 回合的行動

// main.cpp

// 頭文件包含區:略

// 常量區
// <已略去已定義過的內容,以下爲新增內容...>
static const std::string NAME[] = { "YOU", "WEST", "NORTH", "EAST" }; // 各玩家名稱

// 全局變量區
// <已略去已定義過的內容,以下爲新增內容...>
static bool sAIRunning;                        // AI 是否處於忙碌狀態

// 函數聲明區
// <已略去已定義過的內容,以下爲新增內容...>
static void ai();                              // AI 運行過程
static void draw(int = 1);                     // 摸牌過程
static void play(int, Color = NONE);           // 出牌過程

// 入口點
int main() { /* 略 */ }

// AI 運行過程
static void ai() {
	static AI myAI;
	int idxBest, now;
	Color bestColor[1];

	if (sAIRunning) {
		return;
	} // if (sAIRunning)

	sAIRunning = true; // 加鎖
	while (sStatus == Player::COM1
		|| sStatus == Player::COM2
		|| sStatus == Player::COM3) {
		now = sStatus;
		sStatus = STAT_IDLE; // AI 忙碌時屏蔽一切鼠標事件
		idxBest = myAI.bestCardIndex4NowPlayer(bestColor);
		if (idxBest >= 0) {
			// 找到一張合適的可以打出的牌
			play(idxBest, bestColor[0]);
		} // if (idxBest >= 0)
		else {
			// 無牌或無合適的牌可出
			draw();
		} // else
	} // while (sStatus == Player::COM1 || ...)

	sAIRunning = false; // 解鎖
} // ai()

// 回調函數,狀態碼改變時觸發
static void onStatusChanged(int status) {
	cv::Rect rect;
	cv::Size axes;
	cv::Point center;

	switch (status) {
	case STAT_WELCOME:
		refreshScreen("WELCOME TO UNO CARD GAME, CLICK UNO TO START");
		break; // case STAT_WELCOME

	case STAT_NEW_GAME:
		sUno->start();
		refreshScreen("GET READY");
		cv::waitKey(2000);
		sStatus = sUno->getNow();
		onStatusChanged(sStatus);
		break; // case STAT_NEW_GAME

	case Player::YOU:
		refreshScreen("Your turn, play or draw a card");
		break; // case Player::YOU

	case STAT_WILD_COLOR:
		// 當玩家打出黑牌時,需要指定接下來跟牌的顏色。在屏幕中央繪製四種顏色的扇形。
		refreshScreen("^ Specify the following legal color");
		rect = cv::Rect(338, 270, 121, 181);
		sBlank(rect).copyTo(sScreen(rect));
		center = cv::Point(405, 315);
		axes = cv::Size(135, 135);

		// 藍色扇形位於圓的右上方
		cv::ellipse(
			/* img        */ sScreen,
			/* center     */ center,
			/* axes       */ axes,
			/* angle      */ 0,
			/* startAngle */ 0,
			/* endAngle   */ -90,
			/* color      */ RGB_BLUE,
			/* thickness  */ -1,
			/* lineType   */ cv::LINE_AA
		); // cv::ellipse()

		// 綠色扇形位於圓的右下方
		cv::ellipse(
			/* img        */ sScreen,
			/* center     */ center,
			/* axes       */ axes,
			/* angle      */ 0,
			/* startAngle */ 0,
			/* endAngle   */ 90,
			/* color      */ RGB_GREEN,
			/* thickness  */ -1,
			/* lineType   */ cv::LINE_AA
		); // cv::ellipse()

		// 紅色扇形位於圓的左上方
		cv::ellipse(
			/* img        */ sScreen,
			/* center     */ center,
			/* axes       */ axes,
			/* angle      */ 180,
			/* startAngle */ 0,
			/* endAngle   */ 90,
			/* color      */ RGB_RED,
			/* thickness  */ -1,
			/* lineType   */ cv::LINE_AA
		); // cv::ellipse()

		// 黃色扇形位於圓的左下方
		cv::ellipse(
			/* img        */ sScreen,
			/* center     */ center,
			/* axes       */ axes,
			/* angle      */ 180,
			/* startAngle */ 0,
			/* endAngle   */ -90,
			/* color      */ RGB_YELLOW,
			/* thickness  */ -1,
			/* lineType   */ cv::LINE_AA
		); // cv::ellipse()

		// 刷新畫面
		imshow("Uno", sScreen);
		break; // case STAT_WILD_COLOR

	case Player::COM1:
	case Player::COM2:
	case Player::COM3:
		ai();
		break; // case Player::COM1, Player::COM2, Player::COM3

	case STAT_GAME_OVER:
		refreshScreen("Click the card deck to restart");
		break; // case STAT_GAME_OVER

	default:
		break; // default
	} // switch (status)
} // onStatusChanged()

// 當前玩家抓 1 張或更多牌。
// count 參數指定要抓的牌數,調用時若省略則默認爲 1。
static void draw(int count) {
	int i, now;
	cv::Rect roi;
	cv::Mat image;
	Card* drawnCard;
	std::stringstream buff;

	sStatus = STAT_IDLE; // 屏蔽鼠標事件
	now = sUno->getNow();
	for (i = 0; i < count; ++i) {
		buff.str("");
		drawnCard = sUno->draw(now);
		if (drawnCard != nullptr) {
			switch (now) {
			case Player::COM1:
				image = sUno->getBackImage();
				roi = cv::Rect(160, 270, 121, 181);
				if (count == 1) {
					buff << NAME[now] << ": Draw a card";
				} // if (count == 1)
				else {
					buff << NAME[now] << ": Draw " << count << " cards";
				} // else
				break; // case Player::COM1

			case Player::COM2:
				image = sUno->getBackImage();
				roi = cv::Rect(580, 70, 121, 181);
				if (count == 1) {
					buff << NAME[now] << ": Draw a card";
				} // if (count == 1)
				else {
					buff << NAME[now] << ": Draw " << count << " cards";
				} // else
				break; // case Player::COM2

			case Player::COM3:
				image = sUno->getBackImage();
				roi = cv::Rect(1000, 270, 121, 181);
				if (count == 1) {
					buff << NAME[now] << ": Draw a card";
				} // if (count == 1)
				else {
					buff << NAME[now] << ": Draw " << count << " cards";
				} // else
				break; // case Player::COM3

			default:
				image = drawnCard->image;
				roi = cv::Rect(580, 470, 121, 181);
				buff << NAME[now] << ": Draw " + drawnCard->name;
				break; // default
			} // switch (now)

			// 動畫
			image.copyTo(sScreen(roi), image);
			imshow("Uno", sScreen);
			cv::waitKey(300);
			refreshScreen(buff.str());
			cv::waitKey(300);
		} // if (drawnCard != nullptr)
		else {
			buff << NAME[now];
			buff << " cannot hold more than ";
			buff << Uno::MAX_HOLD_CARDS << " cards";
			refreshScreen(buff.str());
			break;
		} // else
	} // for (i = 0; i < count; ++i)

	cv::waitKey(1500);
	sStatus = sUno->switchNow();
	onStatusChanged(sStatus);
} // draw()

// 當前玩家打出手中的一張牌。
// index 參數表示要出的牌在玩家手牌中的索引。
// color 參數僅當打出的牌爲黑牌時纔有效,表示玩家指定的接下來的跟牌顏色。
static void play(int index, Color color) {
	Card* card;
	cv::Rect roi;
	cv::Mat image;
	std::string message;
	int x, y, now, size, width, height, next;

	sStatus = STAT_IDLE; // 屏蔽鼠標事件
	now = sUno->getNow();
	size = int(sUno->getPlayer(now)->getHand().size());
	card = sUno->play(now, index, color);
	if (card != nullptr) {
		image = card->image;
		switch (now) {
		case Player::COM1:
			height = 40 * size + 140;
			x = 160;
			y = 360 - height / 2 + 40 * index;
			break; // case Player::COM1

		case Player::COM2:
			width = 45 * size + 75;
			x = 640 - width / 2 + 45 * index;
			y = 70;
			break; // case Player::COM2

		case Player::COM3:
			height = 40 * size + 140;
			x = 1000;
			y = 360 - height / 2 + 40 * index;
			break; // case Player::COM3

		default:
			width = 45 * size + 75;
			x = 640 - width / 2 + 45 * index;
			y = 470;
			break; // default
		} // switch (now)

		// 動畫
		roi = cv::Rect(x, y, 121, 181);
		image.copyTo(sScreen(roi), image);
		imshow("Uno", sScreen);
		cv::waitKey(300);
		if (sUno->getPlayer(now)->getHand().size() == 0) {
			// 當前玩家剛出掉了手中最後一張牌,遊戲結束
			sStatus = STAT_GAME_OVER;
			onStatusChanged(sStatus);
		} // if (sUno->getPlayer(now)->getHand().size() == 0)
		else {
			// 當前玩家打出一張功能牌或黑牌時,按照遊戲規則走流程
			message = NAME[now];
			switch (card->content) {
			case DRAW2:
				next = sUno->switchNow();
				message += ": Let " + NAME[next] + " draw 2 cards";
				refreshScreen(message);
				cv::waitKey(1500);
				draw(2);
				break; // case DRAW2

			case SKIP:
				next = sUno->switchNow();
				if (next == Player::YOU) {
					message += ": Skip your turn";
				} // if (next == Player::YOU)
				else {
					message += ": Skip " + NAME[next] + "'s turn";
				} // else

				refreshScreen(message);
				cv::waitKey(1500);
				sStatus = sUno->switchNow();
				onStatusChanged(sStatus);
				break; // case SKIP

			case REV:
				if (sUno->switchDirection() == Uno::DIR_LEFT) {
					message += ": Change direction to CLOCKWISE";
				} // if (sUno->switchDirection() == Uno::DIR_LEFT)
				else {
					message += ": Change direction to COUNTER CLOCKWISE";
				} // else

				refreshScreen(message);
				cv::waitKey(1500);
				sStatus = sUno->switchNow();
				onStatusChanged(sStatus);
				break; // case REV

			case WILD:
				message += ": Change the following legal color";
				refreshScreen(message);
				cv::waitKey(1500);
				sStatus = sUno->switchNow();
				onStatusChanged(sStatus);
				break; // case WILD

			case WILD_DRAW4:
				next = sUno->switchNow();
				message += ": Let " + NAME[next] + " draw 4 cards";
				refreshScreen(message);
				cv::waitKey(1500);
				draw(4);
				break; // case WILD_DRAW4

			default:
				message += ": " + card->name;
				refreshScreen(message);
				cv::waitKey(1500);
				sStatus = sUno->switchNow();
				onStatusChanged(sStatus);
				break; // default
			} // switch (card->content)
		} // else
	} // if (card != nullptr)
} // play()

// 刷新屏幕畫面
static void refreshScreen(const std::string& message) { /* 略 */ }

// 回調函數,遊戲窗口中發生鼠標事件時觸發
static void onMouse(int event, int x, int y, int flags, void* param) {
	static Card* card;
	static std::vector<Card*> hand;
	static int index, size, width, startX;

	if (event == cv::EVENT_LBUTTONDOWN) {
		// 只響應鼠標左鍵按下的事件,其他事件(移動、左鍵擡起、右鍵相關事件)一律不響應
		switch (sStatus) {
		case STAT_WELCOME:
			if (y >= 270 && y <= 450 && x >= 580 && x <= 700) {
				// 鼠標點擊了牌背按鈕,開始一局新的 UNO 遊戲
				sStatus = STAT_NEW_GAME;
				onStatusChanged(sStatus);
			} // if (y >= 270 && y <= 450 && x >= 580 && x <= 700)
			break; // case STAT_WELCOME

		case Player::YOU:
			if (y >= 520 && y <= 700) {
				hand = sUno->getPlayer(Player::YOU)->getHand();
				size = int(hand.size());
				width = 45 * size + 75;
				startX = 640 - width / 2;
				if (x >= startX && x <= startX + width) {
					// 鼠標指針位於手牌區域,根據 X 座標判斷點擊的是哪一張牌
					index = (x - startX) / 45;
					if (index >= size) {
						index = size - 1;
					} // if (index >= size)

					// 嘗試打出選擇的手牌(如果可以合法打出的話)
					card = hand.at(index);
					if (card->isWild() && size > 1) {
						sStatus = STAT_WILD_COLOR;
						onStatusChanged(sStatus);
					} // if (card->isWild() && size > 1)
					else if (sUno->isLegalToPlay(card)) {
						play(index);
					} // else if (sUno->isLegalToPlay(card))
				} // if (x >= startX && x <= startX + width)
			} // if (y >= 520 && y <= 700)
			else if (y >= 270 && y <= 450 && x >= 338 && x <= 458) {
				// 鼠標指針位於牌堆區域,從牌堆摸一張牌
				draw();
			} // else if (y >= 270 && y <= 450 && x >= 338 && x <= 458)
			break; // case Player::YOU

		case STAT_WILD_COLOR:
			if (y > 220 && y < 315) {
				if (x > 310 && x < 405) {
					// 紅色扇形區域
					sStatus = Player::YOU;
					play(index, RED);
				} // if (x > 310 && x < 405)
				else if (x > 405 && x < 500) {
					// 藍色扇形區域
					sStatus = Player::YOU;
					play(index, BLUE);
				} // else if (x > 405 && x < 500)
			} // if (y > 220 && y < 315)
			else if (y > 315 && y < 410) {
				if (x > 310 && x < 405) {
					// 黃色扇形區域
					sStatus = Player::YOU;
					play(index, YELLOW);
				} // if (x > 310 && x < 405)
				else if (x > 405 && x < 500) {
					// 綠色扇形區域
					sStatus = Player::YOU;
					play(index, GREEN);
				} // else if (x > 405 && x < 500)
			} // else if (y > 315 && y < 410)
			break; // case STAT_WILD_COLOR

		case STAT_GAME_OVER:
			if (y >= 270 && y <= 450 && x >= 338 && x <= 458) {
				// 遊戲結束後點擊牌堆,意爲重新開局
				sStatus = STAT_NEW_GAME;
				onStatusChanged(sStatus);
			} // if (y >= 270 && y <= 450 && x >= 338 && x <= 458)
			break; // case STAT_GAME_OVER

		default:
			break; // default
		} // switch (sStatus)
	} // if (event == cv::EVENT_LBUTTONDOWN)
} // onMouse()

首先,自己回合的交互。自己回合能做的無非兩種事:從牌堆摸一張牌,或選一張能出的牌打出。現在讓我們定位到鼠標回調函數 onMouse 裏。在 case Player::YOU 分支裏,我們首先判斷鼠標指針是否落在自己的手牌區域裏。如果是,則計算 X 座標相對於區域起始 X 座標的偏移,得出點擊的是哪一張牌。如果這張牌判定爲可以打出,則調用 play 函數執行出牌流程,否則什麼也不做。再假如鼠標指針落在牌堆區域裏,則調用 draw 函數執行摸牌流程。

我們先看 draw 函數。調用 draw 函數需要傳遞一個 count 參數(可省略,默認爲 1),用於說明當前回合玩家抓幾張牌。如果是主動摸牌,那很明顯是隻摸 1 張;而如果是上家對你使用了 +2 或 +4,則調用本函數時需要明示抓幾張。抓牌的過程中,我們需要先把 sStatus 全局變量的值設爲 STAT_IDLE,以屏蔽抓牌過程中捕捉到的任何鼠標事件。然後是播放摸牌動畫。如果是玩家抓牌,則直接在玩家手牌上方畫出剛抓的牌的正面圖案;如果是電腦抓牌,則在相應電腦玩家的手牌旁畫一張牌的背面圖案。等待 300 毫秒後,刷新畫面,可以看到剛抓的牌已經放入手牌中了。特殊地,因爲畫面大小限制,以及爲了照顧玩家的遊戲體驗,本遊戲設置了手牌上限,每位玩家最多隻能持有 14 張手牌。如果超出了這個範圍,則會給出超過上限的提示並中斷摸牌流程。

再來看 play 函數。調用 play 函數需要傳遞一個 index 參數(該參數強制要求調用方指定),以表明當前回合玩家決定打出手中的哪張牌。另一個可選參數 color 是爲黑牌服務的,當玩家打出了一張黑牌時,調用方需要在這個參數裏說明玩家指定的接下來的跟牌顏色是哪種。如果玩家打出的是非黑牌,那麼這個參數沒有實際意義。動畫的部分其實和 draw 函數裏的動畫部分非常相似,讀者請自行推敲其中的含義,我在此不多做敘述。播放完動畫後,我們還要進行兩次判斷。首先,若當前玩家出完了所有的手牌,則遊戲結束,狀態碼切換到 STAT_GAME_OVER;其次,若當前玩家打出了一張功能牌或萬能牌,則需要按照遊戲規則走完規定的流程。打出 +2:令下家摸兩張牌並跳過其回合;打出禁止:令下家跳過其回合;打出反轉:改變接下來的行動順序;打出變色:任意指定接下來的跟牌顏色;打出 +4:任意指定接下來的跟牌顏色,並令下家摸四張牌且跳過其回合;打出其他牌:直接進入下一個人的回合。

如果是電腦打出了一張黑牌,那麼直接由 AI 計算得出要選定的接下來的跟牌顏色就可以了。如果是玩家打出了一張黑牌,那麼在打出這張牌之前,還需要插入一個【指定接下來跟牌的顏色】的過程。在 onMouse 函數的 case Player::YOU 分支裏,我額外做了一次判斷,如果選定的牌是黑牌,那麼先將狀態碼改成 STAT_WILD_COLOR,然後調用 onStatusChanged 函數完成第一次交互。在 onStatusChanged 函數的 case STAT_WILD_COLOR 分支裏,我先把牌堆區域的那張牌背圖案抹掉,然後畫了四個 90° 的扇形。同時 onMouse 的 case STAT_WILD_COLOR 分支裏,根據鼠標指針所在位置判斷位於哪個扇形內,然後調用 play 函數打出黑牌並指定顏色。(這裏做得不夠完善,只有鼠標指針落在扇形的內接正方形裏才能被識別到)

電腦回合的操作,以及遊戲結束後的操作、用戶交互比較簡單,本人不再闡述,請讀者自行解讀。

4.5.5 效果演示

文章的最後,再次告知:源代碼已在 GitHub 上公開,倉庫地址:https://github.com/shiawasenahikari/UnoCard

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章