今天咱们制作一款 Windows 上最经典的游戏 —— 扫雷。扫雷这款游戏已经有很久远的历史了,不过在 Win10 原版操作系统中貌似已经被转移到了应用商店中。扫雷最常见的有两个版本,一个是 XP 的版本,一个是 Win7 的版本,这两个版本核心玩法是一致的,只不过 Win7 的视觉效果更加炫酷而已。
本篇文章的主要内容就是介绍 Win7 版本扫雷的制作过程,排除大量视觉特效的干扰,只关注核心的扫雷玩法,具体实现包括几个核心知识点:
- 地图相关的逻辑。
- 地图初始化操作。
- 地图打开操作。
- 鼠标互动操作逻辑。
- 左键单击。
- 右键单击。
- 左右同时按下。
- 游戏的绘制以及相关细节。
资源提取
在开始之前,我们先介绍一下游戏资源的提取。在网上可以下载 Win7 版本的扫雷原始程序:
下载完成后,我们可以在网上搜索 Windows 资源提取软件 ,这里我找到一个名字叫做 “Redwood” 的程序,用这个程序打开 Minesweeper.dll
这个动态库,就可以看到扫雷游戏制作所需要的素材了。
右键可以将这些资源文件保存到本地。扫雷中主要有三种资源类型:
- WMA 格式的音乐资源,主要是游戏过程中播放的音乐特效。
- JPG/CAB 格式的图片资源,主要是游戏过程中使用的图片资源,其中 .CAB 后缀的资源是 Alpha 通道,需要和 JPG 格式的资源一起使用才可以达到透明的效果。
- XML 格式的文本资源,主要描述图片资源的大小和位置。
扫雷一共有两套素材,一个是高分辨率的素材,一个低分辨率的素材,高分辨率的素材被切分为几个部分,为了方便,这里使用了低分辨率的素材,所以只需要用到三张图片素材,它们分别是:
原版扫雷程序的素材是 JPG 格式的非透明素材,以及一张和它同名的 Alpha 通道素材,将它们加载到程序中后,需要将二者合二为一。为了简化操作,这里直接将它们在程序外合并为一张 PNG 的透明图片,虽然效果没有前者好,但是胜在方便。具体可以使用 PS 软件或者使用 ImageMagick 程序。ImageMagick 程序的命令行示例如下(感觉效果一般,有些地方略有模糊,可能有一些别的选项):
D:\\toolsImageMagick-7.0.10-10-portable-Q16-x64\convert.exe minesweeperSheet11.jpg minesweepersheet11.bmp -compose copyopacity -composite minesweeperSheet11.png
初始化等级信息
有了素材之后,我们就可以制作扫雷游戏了。首先最关键的就是如何表达扫雷游戏中的元素,扫雷和俄罗斯方块类似,都是由一个一个小方块组成的游戏,扫雷中每个小方块都包括很几种状态,这些状态之间有些是可以互相组合的,状态列表如下:
- 是否包含旗帜。
- 是否包含问号。
- 是否包含地雷。
- 是否被打开。
每个方块内部都包含一个数字,表示该方块周围地雷的数量,范围由 0 到 8,正好可以用四个比特位来表示,加上前面四种标记位,每个方块可以用一个 unsigned char
来表示。
扫雷游戏中分为初级、中级和高级,它们的区别就是地图的大小和地雷的数量,初级是 9 * 9 的方阵,中级是 16 * 16 的方阵,高级是 30 * 16 的方阵,为了方便,我们这里直接定义了一个二维数组来表示扫雷的整个地图。
typedef struct Mine
{
unsigned char container[MAX_BLOCK_SIZE][MAX_BLOCK_SIZE]; //容器
int width, height; //当前容器宽高
//...
} Mine;
width 和 height 分别表示当前地图的边长(方块的个数)。虽然有些浪费空间,但是避免了内存动态分配操作。
这里定义一个初始化函数,可以根据当前扫雷的等级来初始化数据:
static void initLevel(Mine* mine, int level)
{
mine->level = level;
if (mine->level == ML_BEGINNER) {
mine->width = 9;
mine->height = 9;
mine->totalMineCount = 10;
}
else if (mine->level == ML_INTERMEDIATE) {
mine->width = 16;
mine->height = 16;
mine->totalMineCount = 40;
}
else if (mine->level == ML_ADVANCED) {
mine->width = 30;
mine->height = 16;
mine->totalMineCount = 99;
}
mine->remainderMineCount = mine->totalMineCount;
memset(mine->container, 0, sizeof(mine->container));
//...
}
代码中 totalMineCount
用来表示当前级别的总雷数,初级包含 10 个地雷,中级包含 40 个地雷,而高级包含 99 个地雷,remainderMineCount
表示当前剩余地雷的数量。
初始化地图数据
上面的代码仅仅初始化了扫雷数据结构的基本属性,并没有初始化扫雷地图中每个方块的内容。扫雷中有个细节,就是如果你第一次点击地图,永远不可能点击到地雷,所以地图中小方块的初始化操作是在鼠标点击之后,游戏中的计时器也是从这时候开始的。
既然第一次不会点击到地雷,那么地图中小方块的初始化需要避开初次点击的位置,如果用变量 r 来表示点击的行,变量 c 来表示点击的列,我们就可以定义一个地图初始化函数:
static int initMap(Mine* mine, int r, int c)
{
//...
}
这个函数比较长,我们简单的拆分讲解一下。该函数一共包含三个部分,做了三件事情:
- 按照顺序填充地雷。
- 随机化地雷位置。
- 按照现有地雷的情况,生成地图中所有小方块所包含的数字。
地雷填充这里就不用过多介绍,需要注意的是,代码中我避开了当前点击的位置以及该位置周围的 8 个方块:
int mineCount = mine->totalMineCount;
for (int i = 0; i < mine->height; i++)
{
for (int j = 0; j < mine->width; j++)
{
//跳过点击位置
if (abs(i-r) <=1 && abs(j-c) <=1 ) {
continue;
}
if (mineCount > 0) {
//设置地雷
setLandmine(mine, i, j, 1);
mineCount--;
}
else
{
break;
}
}
if (mineCount <= 0)
break;
}
随机交换位置也很简单,同样避开点击的位置,防止将点击的位置放置地雷:
//随机化位置
for (int i = 0; i < mine->height; i++)
{
for (int j = 0; j < mine->width; j++)
{
//跳过点击位置
if (abs(i - r) <= 1 && abs(j - c) <= 1) {
continue;
}
//获取一个随机位置
int rx = c, ry = r;
do {
rx = rand() % mine->width;
ry = rand() % mine->height;
} while (abs(ry - r) <= 1 && abs(rx - c) <= 1);
//交换地雷
int state = isLandmine(mine, i, j);
setLandmine(mine, i, j, isLandmine(mine, ry, rx));
setLandmine(mine, ry, rx, state);
}
}
放置好地雷之后,就开始生成我们最终的地图样式。每个小方块的数字都是根据当前地图上地雷的分布情况动态计算得出的:
//计算数字
int foot[8][2] = { {-1, -1},{-1, 0},{-1, 1}, {0, -1}, {0, 1},{1, -1},{1, 0},{1, 1} };
for (int i = 0; i < mine->height; i++)
{
for (int j = 0; j < mine->width; j++)
{
//不是地雷的地方,需要计算生成数字
if (!isLandmine(mine, i, j))
{
//搜集八个方向的地雷数量
int num = 0;
for (int k = 0; k < 8; k++)
{
if ((i + foot[k][0] >= 0 && i + foot[k][0] < mine->height)
&& (j + foot[k][1] >= 0 && j + foot[k][1] < mine->width)
&& isLandmine(mine, (i + foot[k][0]), (j + foot[k][1])))
{
num++;
}
}
//设置地雷数量
setNumber(mine, i, j, num);
}
}
}
地图打开操作
扫雷中的操作主要是用鼠标进行的,鼠标左键可以打开地图,如果点击的位置周围是空白区域,可以一起被打开。这个操作主要是用“广搜”来完成。
整个逻辑简单的描述如下:从鼠标点击的位置开始算起,如果该位置方块的数字大于 0,则直接返回,仅仅打开该位置的方块。如果点击位置方块的数字为 0,则从该位置为起点,向周围 8 个方向所有位置为 0 的方块,依次递归,直到遇见非 0 的方块为止。
“广搜” 是非常成熟的算法,你可以使用递归的方式实现,也可以使用队列的方式实现,这里使用队列的方式,整个代码如下:
//打开方块, (r,c)初始打开位置,不能是地雷,不能是旗帜
static void openBlocks(Mine* mine, int r, int c)
{
//该位置包含数字,仅打开这个位置
if (getNumber(mine, r, c) > 0) {
setOpen(mine, r, c);
return;
}
//搜索方向
int foot[8][2] = { {-1, -1},{-1, 0},{-1, 1}, {0, -1}, {0, 1}, {1, -1},{1, 0},{1, 1} };
int flag[MAX_BLOCK_SIZE][MAX_BLOCK_SIZE] = { 0 };
//搜索队列
Queue q;
if (init(&q, MAX_BLOCK_SIZE * MAX_BLOCK_SIZE) < 0) {
return;
}
//起始位置
Pos tmp = { c, r };
flag[tmp.py][tmp.px] = 1;
setOpen(mine, tmp.py, tmp.px);
push(&q, tmp);
int openCount = 0;
//队列不为空
while (empty(&q) == 0)
{
//获取第一个元素
Pos* p = front(&q);
pop(&q);
//空
if (p == 0) {
continue;
}
//搜索8个方向
for (int i = 0; i < 8; i++)
{
//下一个位置
tmp.py = p->py + foot[i][0];
tmp.px = p->px + foot[i][1];
//不要超出边界且没有搜索过的地方且没有被打开过
if ((tmp.px >= 0 && tmp.px < mine->width)
&& (tmp.py >= 0 && tmp.py < mine->height)
&& flag[tmp.py][tmp.px] != 1)
{
//记录搜索位置
flag[tmp.py][tmp.px] = 1;
//如果是旗帜,跳过
if (isFlag(mine, tmp.py, tmp.px) == 1) {
continue;
}
//当前位置数字为空
if (0 == getNumber(mine, tmp.py, tmp.px))
{
push(&q, tmp);
}
//没有雷且没有打开,则打开
if (isLandmine(mine, tmp.py, tmp.px) != 1 && !isOpen(mine, tmp.py, tmp.px)) {
setOpen(mine, tmp.py, tmp.px);
openCount++;
}
}
}
}//while
uninit(&q);
}
代码中 flag 二维数组用来标记搜索过的位置,防止发生重复搜索的情况。而 Queue 结构体是手写的简单队列,用来完成 “广搜” 这一操作,整个队列的结构和接口如下:
typedef struct Pos {
int px, py;
}Pos;
typedef struct Queue
{
Pos* items;
int len;
int front;
int rear;
}Queue;
int init(Queue* q, int len);
void uninit(Queue* q);
int push(Queue* q, Pos v);
int empty(Queue* q);
Pos* front(Queue* q);
void pop(Queue* q);
队列是用一个循环数组的方式来表达的,结构比较简单,这里不细表。
鼠标左键点击
扫雷的操作主要由鼠标来完成,这里先讲一下扫雷的左键逻辑。扫雷中鼠标左键被用来打开当前地图上的方块,但是如果你仔细研究,就会发现方块被打开发生在鼠标左键抬起之后,而不是鼠标左键按下的时候,这一点非常重要,如果你按下鼠标左键不放,是可以在地图上方游走的。
如果你在地图外面松开左键,是不会触发方块开启操作的。所以这里重点在于捕获鼠标左键抬起消息:
//触发事件
void processGameEvent(SystemModule* pModule, SDL_Event* evt)
{
if (evt->type == SDL_MOUSEBUTTONUP) {
if (evt->button.button == SDL_BUTTON_LEFT) {
setMouseLButtonUp(&mine, evt->button.x, evt->button.y);
}
}
}
函数 setMouseLButtonUp
用来实现鼠标左键抬起的逻辑,函数后两个参数是鼠标左键抬起时指针所在的位置。
//鼠标左键弹起
void setMouseLButtonUp(Mine* mine, int x, int y)
{
//如果点击的位置是旗帜
if (isFlag(mine, mine->mouseBlockY, mine->mouseBlockX)) {
return;
}
//如果已经打开,忽略
if (isOpen(mine, mine->mouseBlockY, mine->mouseBlockX)) {
return;
}
//...
}
函数中首先做了一些逻辑上的状态排除操作。因为游戏中如果在已经开启的方块上点击鼠标是没有任何作用的,并且如果方块上方被标记为旗帜,则该方块也无法被鼠标左键开启,这也是为了防止误操作导致游戏意外结束。
如果不是上述两种情况,则开始执行真正的游戏逻辑。如果游戏在初始状态,鼠标左键的抬起事件会触发地图的初始化以及方块打开操作,并开始计时。如果游戏处于运行状态,则要判断点击的位置是否为地雷,如果是地雷直接结束,否则执行默认的方块打开操作。具体的代码如下:
//鼠标左键弹起
void setMouseLButtonUp(Mine* mine, int x, int y)
{
//...
//左键点击初始化
if (mine->state == RS_INIT)
{
//初始化地图
int ret = initMap(mine, mine->mouseBlockY, mine->mouseBlockX);
if (ret < 0) {
SDL_Log("Init Map failed!");
}
//打开方块
openBlocks(mine, mine->mouseBlockY, mine->mouseBlockX);
mine->tick = 0.0f;
mine->elapsedTime = 10; //从 1s 开始计时
mine->state = RS_RUN;
}
else if(mine->state == RS_RUN)
{
//如果打开的方块是雷
if (isLandmine(mine, mine->mouseBlockY, mine->mouseBlockX)) {
setGameOver(mine, mine->mouseBlockY, mine->mouseBlockX);
return;
}
//打开方块
openBlocks(mine, mine->mouseBlockY, mine->mouseBlockX);
}
}
代码中的 mouseBlockY
和 mouseBlockX
分别代表着当前鼠标所在方块的行与列。
鼠标右键点击
说完鼠标左键,咱们再聊一聊鼠标右键的逻辑。鼠标右键主要是用来标记当前方块的属性,是地雷(旗帜)还是不确定(问号),这两个状态标记是互斥的,且可以相互转换,转化的关系如下:
空 -> 旗帜 -> 问号 -> 空
根据右键点击的次数,依次递进。
这里需要注意的是标记的过程中,是鼠标点击的时候就进行了,而不是按键抬起之后。
//鼠标右键按下
void setMouseRButtonDown(Mine* mine, int x, int y)
{
//如果未打开
if (!isOpen(mine, mine->mouseBlockY, mine->mouseBlockX))
{
//如果是问号
if (isQuestion(mine, mine->mouseBlockY, mine->mouseBlockX))
{
//设置为空
cleanMark(mine, mine->mouseBlockY, mine->mouseBlockX);
}
//如果是小旗
else if (isFlag(mine, mine->mouseBlockY, mine->mouseBlockX))
{
//减少地雷计数
mine->remainderMineCount++;
//设置问号
setQuestion(mine, mine->mouseBlockY, mine->mouseBlockX);
}
else
{
//还原地雷计数
mine->remainderMineCount--;
//设置该位置为小旗
setFlag(mine, mine->mouseBlockY, mine->mouseBlockX);
}
}
}
代码中不要忘记,随着方块标记的转变,地雷的显示数量也随之改变。这里还有一个小细节,就是鼠标的右键操作并不会导致游戏开始计时,换句话说右键操作并不会让游戏进入运行状态。
鼠标左右键同时按下
鼠标左右键的触发逻辑是比较蛋疼的,原因是在 SDL2 中事件响应是依次进行的,也就是说即使你同时按下鼠标左右键,左右键的事件响应也是分别进行的,并且这个顺序还是未知的。
如果你想查看当前是否同时按下左右键,则必须自己维护鼠标按键的当前状态。这里定义了一个整形变量,用来记录鼠标的按下状态:
//扫雷
typedef struct Mine
{
int mouseX, mouseY; //鼠标像素位置
int mouseBlockX, mouseBlockY; //鼠标方块坐标
int btnDown; //鼠标是否按下
//...
}
鼠标左键用 1 表示,鼠标右键用 2 表示,如果二者同时按下,则变量 btnDown 的值为 3。
//按键
typedef enum MouseKey {
MK_LEFT = 1,
MK_RIGHT = 2
}MouseKey;
代码中可以分别捕获鼠标左、右键的按下和抬起事件,在按下事件中使用或运算,记录按下状态:
//鼠标左键按下
void setMouseLButtonDown(Mine* mine, int x, int y)
{
mine->btnDown |= MK_LEFT;
}
//鼠标右键按下
void setMouseRButtonDown(Mine* mine, int x, int y)
{
mine->btnDown |= MK_RIGHT;
}
在按键抬起的时候,使用且运算消除状态:
//鼠标左键弹起
void setMouseLButtonUp(Mine* mine, int x, int y)
{
mine->btnDown &= (~MK_LEFT);
}
//鼠标右键弹起
void setMouseRButtonUp(Mine* mine, int x, int y)
{
mine->btnDown &= (~MK_RIGHT);
}
我们可以使用下面这条语句来判断是否同时按下左右双键:
(mine->btnDown & MK_LEFT) && (mine->btnDown & MK_RIGHT) ? 1 : 0
理论上这样的判断就已经足够了,但是扫雷游戏中双键同时按下的操作逻辑会在按键抬起之后才会执行,所以使用上面的语句还不够,因为上面这个状态只能记录当前的情况,为了延迟到鼠标抬起后触发自动打开方块的逻辑,需要记录上一个状态,在左键或者右键抬起之后,检查上一个状态是否为双键同时按下,只有同时按下才会触发自动打开操作:
//鼠标左键弹起
void setMouseLButtonUp(Mine* mine, int x, int y)
{
//前一个状态是同时按下两键
int prevKeyState = (mine->btnDown & MK_LEFT) && (mine->btnDown & MK_RIGHT) ? 1 : 0;
mine->btnDown &= (~MK_LEFT);
//自动开启
if (prevKeyState) {
//左右同时抬起,移除右键抬起响应
mine->btnDown &= (~MK_RIGHT);
autoOpenBlocks(mine);
return;
}
}
//鼠标右键弹起
void setMouseRButtonUp(Mine* mine, int x, int y)
{
//前一个状态是同时按下两键
int prevKeyState = (mine->btnDown & MK_LEFT) && (mine->btnDown & MK_RIGHT) ? 1 : 0;
mine->btnDown &= (~MK_RIGHT);
//自动开启
if (prevKeyState) {
//左右同时抬起,移除左键抬起响应
mine->btnDown &= (~MK_LEFT);
autoOpenBlocks(mine);
return;
}
}
代码中的自动打开操作是扫雷游戏的基本规则。如果在双击(左右键)的位置存在一个数字,且周围 8 个方块上方已经被标记上了和数字相同的旗帜,则同时点击鼠标左右键会自动打开周围未标记的方块。
代码实现非常简单,就是统计点击位置周围的标记数量,标记数量和显示数字一致的话,打开剩余方块:
//自动开启
static void autoOpenBlocks(Mine* mine) {
//当前未打开,忽略
if (!isOpen(mine, mine->mouseBlockY, mine->mouseBlockX)) {
return;
}
//没有数字,忽略
int disNum = getNumber(mine, mine->mouseBlockY, mine->mouseBlockX);
if (disNum < 1) {
return;
}
//查看四周标记雷的个数是否为num个,不是忽略(提示错误)
int foot[8][2] = { {-1, -1},{-1, 0},{-1, 1}, {0, -1}, {0, 1},{1, -1},{1, 0},{1, 1} };
int flagNum = 0;
for (int i = 0; i < 8; i++) {
int r = mine->mouseBlockY + foot[i][0];
int c = mine->mouseBlockX + foot[i][1];
if (r >= 0 && r < mine->height && c >= 0 && c < mine->width
&& isFlag(mine, r, c) == 1) {
flagNum++;
}
}
if (disNum != flagNum) {
//提示错误提示
Mix_PlayChannel(-1, mine->effects[ET_INVALIDMOVE], 0);
mine->frameIndex[mine->mouseBlockY][mine->mouseBlockX] = 5;
return;
}
//如果相等,则直接打开现有没有打开的方块(可能会直接gameover)
for (int i = 0; i < 8; i++) {
int r = mine->mouseBlockY + foot[i][0];
int c = mine->mouseBlockX + foot[i][1];
if (r >= 0 && r < mine->height && c >= 0 && c < mine->width && isFlag(mine, r, c) == 0) {
//打开该位置
if (isLandmine(mine, r, c)) {
//直接狗带
setGameOver(mine, r, c);
return;
}
else {
//需要成片开启,否则会出现没有数字的空域未开启方块
openBlocks(mine, r, c);
}
}
}
}
这里有个小细节,就是打开的时候并不是仅仅打开周围的 8 个方块,如果这 8 个方块中存在空白的情况,会触发成片开启的情况。代码逻辑上可以直接调用前面实现的 openBlocks
函数。
游戏绘制以及其它细节
游戏的绘制其实没什么可以说的,就是根据当前扫雷的数据结构绘制相应的场景即可。但是因为扫雷的游戏中包含的情况非常多,涉及到各种细节,简单说几个:
- 当前鼠标所在的位置需要高亮。
- 扫雷初始化伴随着初始动画。
- 鼠标的交互会产生不同的方块样式。
- 地图从左上角到右下角存在光照的变化。
- 打开的方块在左侧和上方存在阴影特效。
- 双击无法触发自动打开逻辑会产生错误提示。
- 游戏失败和成功都会触发场景特效。
- 窗口可以伸缩,并且可以更加不同的等级显示不同的大小。
- 等等……
细节太多,这里就不具体介绍了,不管多复杂,本质上都是由 SDL_RenderCopy
搭建起来的,只不过业务逻辑上比较麻烦而已。
扫雷这个游戏刚开始写的时候,我以为很简单,但是直到写了一半的时候,我发现 Win7 这个扫雷涉及的细节太多了,有些特效实现根本无法下手,无法理解背后的逻辑是怎么做到的,只能有个模糊的感觉,整体上对这次的代码不是很满意,大概仿制了 80 % 左右吧,如果有机会继续完善,不过希望渺茫,因为仿制的烦了,太多细节需要处理,要一遍又一遍的研究原版程序,举个例子,你知道扫雷的雷数可以变为负数吗?那你知道这个负数最小是多少么?