您的位置:首页 > 产品设计 > UI/UE

思考的救赎(一):三消游戏实现探索

2017-09-19 22:11 127 查看

一、引言

有天中午正好看到有同事在玩消消乐,也即是出名的三消类游戏,顿时引发了我想要探索实现一个三消类游戏的兴趣。

花了两天的探索,终于有了一个半成品,这篇博客就将我这两天的思考过程记录下来,以飨读者。



上图是我的半成品三消游戏 Demo 的效果展示。基本完成了以下功能:

1. 随机初始化网格

2. 消除操作

3. 消除提示

尚有以下功能尚未实现(其他三消游戏一般都有的):

1. 消除后重力随机补充

2. 分数计算

这个 Demo 尽管使用了一个不是那么恰当的 UI 库 SOUI(这是用来做应用软件的并非专长于游戏领域),但是秉承于重在体验研究算法轻于实现效果,本人还是怎么轻松怎么来了(T_T)。

那么,接下来就开始我们的三消游戏实现之旅吧!

二、需求探讨:参考游戏HeroEmblems

要做一个三消游戏,那么最简单的例子就是找现成的一个比较成功的游戏来观摩学习:



这是我比较钟爱的一款三消游戏(花了我25RMB呢!),我们看看这个界面,可以得到以下需求:

1. 一共有 7 行 8 列,共 56 个格子

2. 一共有 4 种图像按钮

3. 横向或者纵向至少连成 3 个即可消去这 3 个按钮,并且在重力影响下移动相应受影响格子;另外,在移动的过程中若遇到了 3 个相连则继续消去,直到没有 3 个相连元素为止

4. 初始化的时候,必然没有任意行或者列有 3 个元素相连

根据上述规则,我们很容易得知自己要做什么了,那么接下来,我们把自己要做的工作分为三个步骤:

1. 实现初始化随机网格(并且保证没有 3 个相连的情况发生)

2. 实现消除功能

3. 实现消除补充(尚未实现)

三、磨刀:数据结构设计

在开始我们的开发之前,我们还要注意最基本也就是非常重要的一步,那就是数据结构设计。

让我们看看,这里需要怎样的数据结构呢?

1. PosPoint: 表示一个坐标点。

这个毋庸置疑,我们要操作一个格子,必然要通过坐标来访问,这里定一个坐标点结构也能够方便我们的开发过程。

// 一个坐标点
struct PosPoint
{
PosPoint() : row(0), col(0) {}
PosPoint(int row, int col) : row(row), col(col) {}
PosPoint SetPos(int row, int col) {
row = row, col = col;
return PosPoint(row, col);
}
int row;
int col;
};


2. GridStatus: 表示一个格子的状态。

我们看到,我们的格子有四种图案按钮以及一个删除样式(总共五种),这里定义成 enum 类型方便我们使用。

// 一个格子的一种状态
enum GridStatus {
Grid_None = 0,
Grid_Star = 1,
Grid_Heart = 2,
Grid_Sword = 3,
Grid_Shield = 4,
Grid_Delete = 5
};


3. Grid: 表示一个格子。

我们定义了格子的坐标、状态,自然还要定义一个总的格子结构。通过它,我们可以访问到格子的坐标、状态等等信息。

// 一个格子
struct Grid {
Grid() : point(0, 0), status(Grid_None) {}
Grid(int row, int col, GridStatus status = Grid_None) :
point(row, col), status(status) {}
PosPoint point;
GridStatus status;
};


4. 数组的数组:表示我们的网格信息。

我们使用了
std::vector
的二重方式来实现这个网格结构。

std::vector<std::vector<Grid>> m_vecNet;


那么现在,我们现在定义好了网格,定义好了格子,甚至连格子的状态、坐标都定义好了,剩下的就是在程序中填充这些数据结构并且使用它们来实现我们的算法逻辑即可,那么接下来,让我们好好考虑这么一个问题:

我们如何实现初始化随机一个没有 3 个相同元素相连的网格呢?

四、暴力的随机初始化

根据我们上述的需求分析,我们在初始化的时候需要实现这么两件事情:

1. 随机显示 4 种图像按钮

2. 保证没有 3 个相同的图像按钮相连

其中,第 1 个条件其实很好实现,我们写一个随机函数,取模为 4,然后每次随机到一个值将其与格子的状态关联起来即可。而第 2 个条件我思考了很久,甚至开始计算三消的可能性有多少,该如何去初始化设置,结果发现还是太复杂,最后的最后,用了一个比较“暴力”的方法解决了。先让我们看看第 1 个条件是怎么实现的吧。

1. 随机显示 4 种图像按钮

以下是我随机函数的代码:

// 获取随机数
// modular  随机数发生器范围,0开始
// excepts  在随机数发生器范围内的不计入随机运算的数字
int MyHelper::Random(int modular, std::vector<int> excepts)
{
// 判断:excepts 是否占用了所有的 modular 的值
if (modular <= excepts.size()) return -1;
// 产生一个值
int random = std::rand() % modular;
// 判断该值是否被剔除不考虑,如果是的话,则再生成一个随机数,直到生成的随机数
// 值不在被剔除值的范围中之后,才确定返回其值
while (std::find(excepts.cbegin(), excepts.cend(), random) != excepts.cend()) {
random = std::rand() % modular;
}
return random;
}


注意了,MyHelper 是一个单例实现的全局可访问的帮助类,其中包含了这个随机发生器。而我们知道 C++ 要实现一个随机发生器,需要以当前时间为种子进行随机数的计算,而此函数内并没有种子的设置呀?原因是我将其放到了 MyHelper 的构造函数里:

MyHelper::MyHelper()
{
std::srand(time(0));
...
}


这样,我们每次访问这个随机数发生器,都能得到不同的随机数啦。

随机数都得到了,初始化这个随机网格也是非常简单的了:

// 随机产生一个阵列
void NetMatrix::RandomNet(std::vector<std::vector<Grid>>& vecNet)
{
for (int i = 0; i < NET_ROW_NUMBER; ++i) {
for (int j = 0; j < NET_COL_NUMBER; ++j) {
int random = MyHelper::Instance()->Random(4);
switch (random)
{
case 0: vecNet[i][j].status = Grid_Star; break;
case 1: vecNet[i][j].status = Grid_Heart; break;
case 2: vecNet[i][j].status = Grid_Sword; break;
case 3: vecNet[i][j].status = Grid_Shield; break;
}
}
}
}


至于 SOUI 是如何显示出来的,这里我设置了 7 行 8 列的 56 个按钮,根据这个随机二维数组的每个元素的 status 的值设置不同的 skin 就可以了。

2. 保证没有 3 个相同的图像按钮相连

那么第 2 个条件该如何实现呢?

是要去思考如何摆放指定形状的三消图案吗?这难免也太复杂了!

那么该怎么做?

答案是:既然我们随机到了有三消可能的矩阵,我们就舍弃它再次随机,直到我们随机到没有三消可能的矩阵的时候,我们才将其显示出来:)

是不是非常非常的“暴力”!

其实就这里的需求而言,这是最好的办法了,四种图像共 56 个格子,怎么也不会耗费太多时间。

以下是我们检查当前随机矩阵是否合法(没有三消可能)的函数:

// 是否合法阵列
bool NetMatrix::ValidNet(std::vector<std::vector<Grid>> vecNet)
{
// 行检查是否有连续 3 个
for (int i = 0; i < NET_ROW_NUMBER; ++i) {
for (int j = 0; j < NET_COL_NUMBER - 2; ++j) {
if (vecNet[i][j].status == vecNet[i][j + 1].status &&
vecNet[i][j + 1].status == vecNet[i][j + 2].status) {
return false;
}
}
}
// 列检查是否有连续 3 个
for (int j = 0; j < NET_COL_NUMBER; ++j) {
for (int i = 0; i < NET_ROW_NUMBER - 2; ++i) {
if (vecNet[i][j].status == vecNet[i + 1][j].status &&
vecNet[i + 1][j].status == vecNet[i + 2][j].status) {
return false;
}
}
}
// 检查通过
return true;
}


至此,我们应该可以显示出一个满足条件的初始化网格啦!

那么接下来,我们就要思考,如何实现消除功能了。

五、消除:两个格子之间的游戏

消除操作,其实就是两个格子之间的游戏而已。

怎么说呢?我们每次尝试消除,都会移动两个格子,我们只需要将两个格子尝试性进行移动,然后将变化后的矩阵进行计算,看是否有满足三消的地方,如果有的话,则消除成功,否则消除失败。

也就是说,这里实现消除成功与否的判断,是非常简单的。

另外,我们还应该考虑的是,我们需要将消除的格子都计算出来,然后将其设置为删除样式(以便后期功能设置),这一块就会涉及到相对来说比较复杂的算法。不过现在让我们慢慢来,先看看如何判断消除成功与否。

1. 消除成功与否的判断

值得多说依据的是:首先,我们通过 SOUI 的按钮事件响应到了这两个按钮的坐标位置,然后我们再进行消除操作的计算。这里的响应操作,也就是按钮的点击触发了消除事件的发生,是有一套 C++ 的回调函数的书写方式的,除了这一块,还有消除成功后进行新矩阵的绘制这一块,即新矩阵生成触发了网格重新绘制事件的发生,也使用到了 C++ 的回调函数的编写,有关这一块内容,虽然不是本篇博客的重点,但是也是挺重要的实用技能,想要详细了解的同学可以参看我的这篇博客:

C++简单实现回调机制

在充分了解了如何从第二个按钮被点击到触发了消除事件的过程之后,我们终于可以安心的来思考如何进行消除成功的判断了,其实也非常简单,交换这两个点,然后将新矩阵进行合法性判断(是否有三消可能),如果不合法则证明消除成功,否则失败。

// 临时缓存处理内容
auto vecNet = m_vecNet;
...
// 交换这两个点
std::swap(vecNet[pre.row][pre.col], vecNet[cur.row][cur.col]);
// 检查是否能够消去
if (!ValidNet(vecNet))
MyHelper::Instance()->WriteLog(L"消除成功!");
else {
MyHelper::Instance()->WriteLog(L"消除失败!");
return false;
}


这里的 WriteLog 函数是我用来向界面状态显示框内写入日志的函数(也放到了帮助类 MyHelper 之中,这里使用了单例访问方式访问 MyHelper 对象)。

2. 获取到消除点信息

如何获取到消除点的信息呢?

我们还是不要忘了这一点,消除操作其实是两个按钮之间的游戏,那么要看有哪些点可以消除,我们就从这两个点下手即可:

可以消除的点必然有以下特征:

1. 与两个点之中的其中一个的状态相同的点在同一个方向上至少有 3 个

2. 两个状态不同的两个点(删除样式除外)可以在一次消除操作中实现两种图案按钮的消除

也就是说,我们只需要拿着这两个操作点,向横纵两个方向进行遍历查询,只要同一方向上有大于等于 3 个的同样状态的格子,我们就可以标记其为消除点了。

计算一个操作点的消除点个数代码如下:

// 计算消除点
std::vector<PosPoint> NetMatrix::GetCancelPoints(PosPoint point, std::vector<std::vector<Grid>> vecNet)
{
// 由此起点开始发散查询
std::vector<PosPoint> horizontalPoints, verticalPoints;
GridStatus status = vecNet[point.row][point.col].status;
// 向左
for (int left = point.col - 1; left >= 0; --left) {
if (vecNet[point.row][left].status == status)
horizontalPoints.push_back(PosPoint(point.row, left));
else break;
}
// 向右
for (int right = point.col + 1; right < NET_COL_NUMBER; ++right) {
if (vecNet[point.row][right].status == status)
horizontalPoints.push_back(PosPoint(point.row, right));
else break;
}
// 向上
for (int up = point.row - 1; up >= 0; --up) {
if (vecNet[up][point.col].status == status)
verticalPoints.push_back(PosPoint(up, point.col));
else break;
}
// 向下
for (int down = point.row + 1; down < NET_ROW_NUMBER; ++down) {
if (vecNet[down][point.col].status == status)
verticalPoints.push_back(PosPoint(down, point.col));
else break;
}
// 检查结果是否合理
// 1. 水平或竖直方向上的个数小于等于 2,则该方向数值舍弃(之所以是 2,是因为
// 基准点在最后加上)
if (horizontalPoints.size() < 2) horizontalPoints.clear();
if (verticalPoints.size() < 2) verticalPoints.clear();
// 2. 将两个集合的点合并
std::vector<PosPoint> results;
for (auto h = horizontalPoints.begin(); h < horizontalPoints.end(); ++h)
results.push_back(*h);
for (auto v = verticalPoints.begin(); v < verticalPoints.end(); ++v)
results.push_back(*v);
if (horizontalPoints.size() >= 2 || verticalPoints.size() >= 2)
results.push_back(point);
return results;
}


我们对上述函数调用两次,计算两个操作点的消除点集合之后,再进行合并,即可计算出所有的消除点了:

// 消除成功,输出消除信息:两个点都需要进行查询
std::vector<PosPoint> prePoints = GetCancelPoints(pre, vecNet);
std::vector<PosPoint> curPoints = GetCancelPoints(cur, vecNet);
SOUI::SStringW strPoint, strPointsMsg, strCancelMsg;
for (auto it = prePoints.begin(); it != prePoints.end(); ++it)
strPointsMsg += strPoint.Format(L"(%d, %d) ", it->row, it->col);
for (auto it = curPoints.begin(); it != curPoints.end(); ++it)
strPointsMsg += strPoint.Format(L"(%d, %d) ", it->row, it->col);
strCancelMsg.Format(L"消除了以下点:%s", strPointsMsg);
MyHelper::Instance()->WriteLog(strCancelMsg);


之后的代码逻辑就很简单了,设置所有的消除点的状态为删除状态,然后重新绘制网格即可(此处详情可查看我的代码)。

至此,到目前为止实现的功能里的核心逻辑我们就已经全部实现啦!

六、展望

对于还未实现功能的思考:

1. 重力随机补充:现在所有消除点的位置信息都已经知道了,剩下的就是计算随机然后补充上去即可。复杂的就是在这个过程中,有可能会发生二次甚至多次消除,三消的计算会比较复杂。

2. 动画效果的实现:这一点限制于界面库以及本人能力,还得深入思考:(

七、总结

茶饭不思花了两天时间,总算是搞出来一个简单的 Demo。说实话,成就感爆棚,还是很开心的:)

虽然本人能力有限,但是在探索并且独立思考的过程中,真的能够学到很多东西,甚至于说熟悉自己已经掌握的技能,以及重要的架构能力。

再厉害的库也只是一块积木

再厉害的框架也只是一个工作台

再厉害的语言也只是你的一双手

只要思想是你的

那么整个世界就是你的

共勉:To be Stronger!

ps: 本篇博客的实验代码可以在这里获得,欢迎分享学习拍砖:

wangying2016/ThreeClearGame
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息