今天咱们制作一款 Windows 上最经典的游戏 —— 扫雷。扫雷这款游戏已经有很久远的历史了,不过在 Win10 原版操作系统中貌似已经被转移到了应用商店中。扫雷最常见的有两个版本,一个是 XP 的版本,一个是 Win7 的版本,这两个版本核心玩法是一致的,只不过 Win7 的视觉效果更加炫酷而已。

本篇文章的主要内容就是介绍 Win7 版本扫雷的制作过程,排除大量视觉特效的干扰,只关注核心的扫雷玩法,具体实现包括几个核心知识点:

  • 地图相关的逻辑。
    • 地图初始化操作。
    • 地图打开操作。
  • 鼠标互动操作逻辑。
    • 左键单击。
    • 右键单击。
    • 左右同时按下。
  • 游戏的绘制以及相关细节。

1. 资源提取

在开始之前,我们先介绍一下游戏资源的提取。在网上可以下载 Win7 版本的扫雷原始程序:

Win7 扫雷

下载完成后,我们可以在网上搜索 Windows 资源提取软件 ,这里我找到一个名字叫做 “Redwood” 的程序,用这个程序打开 Minesweeper.dll 这个动态库,就可以看到扫雷游戏制作所需要的素材了。

资源提取软件

右键可以将这些资源文件保存到本地。扫雷中主要有三种资源类型:

  • WMA 格式的音乐资源,主要是游戏过程中播放的音乐特效。
  • JPG/CAB 格式的图片资源,主要是游戏过程中使用的图片资源,其中 .CAB 后缀的资源是 Alpha 通道,需要和 JPG 格式的资源一起使用才可以达到透明的效果。
  • XML 格式的文本资源,主要描述图片资源的大小和位置。

扫雷一共有两套素材,一个是高分辨率的素材,一个低分辨率的素材,高分辨率的素材被切分为几个部分,为了方便,这里使用了低分辨率的素材,所以只需要用到三张图片素材,它们分别是:

扫雷素材

原版扫雷程序的素材是 JPG 格式的非透明素材,以及一张和它同名的 Alpha 通道素材,将它们加载到程序中后,需要将二者合二为一。为了简化操作,这里直接将它们在程序外合并为一张 PNG 的透明图片,虽然效果没有前者好,但是胜在方便。具体可以使用 PS 软件或者使用 ImageMagick 程序。ImageMagick 程序的命令行示例如下(感觉效果一般,有些地方略有模糊,可能有一些别的选项):

D:\tools\ImageMagick-7.0.10-10-portable-Q16-x64\convert.exe  minesweeperSheet11.jpg  minesweepersheet11.bmp  -compose copyopacity -composite minesweeperSheet11.png

2. 初始化等级信息

有了素材之后,我们就可以制作扫雷游戏了。首先最关键的就是如何表达扫雷游戏中的元素,扫雷和俄罗斯方块类似,都是由一个一个小方块组成的游戏,扫雷中每个小方块都包括很几种状态,这些状态之间有些是可以互相组合的,状态列表如下:

  • 是否包含旗帜。
  • 是否包含问号。
  • 是否包含地雷。
  • 是否被打开。

每个方块内部都包含一个数字,表示该方块周围地雷的数量,范围由 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 表示当前剩余地雷的数量。

3. 初始化地图数据

上面的代码仅仅初始化了扫雷数据结构的基本属性,并没有初始化扫雷地图中每个方块的内容。扫雷中有个细节,就是如果你第一次点击地图,永远不可能点击到地雷,所以地图中小方块的初始化操作是在鼠标点击之后,游戏中的计时器也是从这时候开始的。

既然第一次不会点击到地雷,那么地图中小方块的初始化需要避开初次点击的位置,如果用变量 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);
        }
    }
}

4. 地图打开操作

扫雷中的操作主要是用鼠标进行的,鼠标左键可以打开地图,如果点击的位置周围是空白区域,可以一起被打开。这个操作主要是用“广搜”来完成。

整个逻辑简单的描述如下:从鼠标点击的位置开始算起,如果该位置方块的数字大于 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);

队列是用一个循环数组的方式来表达的,结构比较简单,这里不细表。