源代码已在 GitHub 上公开。仓库地址:GitHub - shiawasenahikari/UnoCard: A simple UNO card game running on PC and Android devices.
第一步:准备卡牌图片资源
进入维基百科 UNO 词条,下载矢量图资源。地址:https://commons.wikimedia.org/wiki/File:UNO_cards_deck.svg
下载完成后,将其转为 PNG 位图,并对图像进行切割,提取出其中的每一张卡牌图像。然后对提取出的卡牌再做调整大小、调色等处理,最终得到以下位图资源。为了良好的屏幕显示效果,这里所有的卡牌图像大小均调整为 121x181。(可以到我的 github 仓库里提取已经做好的图片素材,地址:UnoCard/UnoCard/resource at master · shiawasenahikari/UnoCard · GitHub)
我把除牌背外的每张牌都额外做了一次暗色处理,准备在画面显示中,用暗色牌表示“不可出的牌”,用亮色牌代表“可以出的牌”。另外,带颜色的黑牌也没有做暗色处理。这里带颜色的黑牌只用来指示打出这张牌的玩家指定的接下来的合法颜色是什么,只会出现在“最近打出的牌”里,而不会出现在玩家的手牌里。实际的 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/front_r@.png");
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/front_b@.png");
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/front_g@.png");
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/front_y@.png");
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/dark_r@.png");
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/dark_b@.png");
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/dark_g@.png");
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/dark_y@.png");
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 上公开,仓库地址:GitHub - shiawasenahikari/UnoCard: A simple UNO card game running on PC and Android devices.