5. 鼠标左键点击

扫雷的操作主要由鼠标来完成,这里先讲一下扫雷的左键逻辑。扫雷中鼠标左键被用来打开当前地图上的方块,但是如果你仔细研究,就会发现方块被打开发生在鼠标左键抬起之后,而不是鼠标左键按下的时候,这一点非常重要,如果你按下鼠标左键不放,是可以在地图上方游走的。

左键互动

如果你在地图外面松开左键,是不会触发方块开启操作的。所以这里重点在于捕获鼠标左键抬起消息:

//触发事件
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);
	}
}

代码中的 mouseBlockYmouseBlockX 分别代表着当前鼠标所在方块的行与列。

6. 鼠标右键点击

说完鼠标左键,咱们再聊一聊鼠标右键的逻辑。鼠标右键主要是用来标记当前方块的属性,是地雷(旗帜)还是不确定(问号),这两个状态标记是互斥的,且可以相互转换,转化的关系如下:

空 -> 旗帜 -> 问号 -> 空

根据右键点击的次数,依次递进。

右键互动

这里需要注意的是标记的过程中,是鼠标点击的时候就进行了,而不是按键抬起之后。

//鼠标右键按下
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);
		}
	}
}

代码中不要忘记,随着方块标记的转变,地雷的显示数量也随之改变。这里还有一个小细节,就是鼠标的右键操作并不会导致游戏开始计时,换句话说右键操作并不会让游戏进入运行状态。

7. 鼠标左右键同时按下

鼠标左右键的触发逻辑是比较蛋疼的,原因是在 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 函数。

8. 游戏绘制以及其它细节

游戏的绘制其实没什么可以说的,就是根据当前扫雷的数据结构绘制相应的场景即可。但是因为扫雷的游戏中包含的情况非常多,涉及到各种细节,简单说几个:

  • 当前鼠标所在的位置需要高亮。
  • 扫雷初始化伴随着初始动画。
  • 鼠标的交互会产生不同的方块样式。
  • 地图从左上角到右下角存在光照的变化。
  • 打开的方块在左侧和上方存在阴影特效。
  • 双击无法触发自动打开逻辑会产生错误提示。
  • 游戏失败和成功都会触发场景特效。
  • 窗口可以伸缩,并且可以更加不同的等级显示不同的大小。
  • 等等……

细节太多,这里就不具体介绍了,不管多复杂,本质上都是由 SDL_RenderCopy 搭建起来的,只不过业务逻辑上比较麻烦而已。

扫雷这个游戏刚开始写的时候,我以为很简单,但是直到写了一半的时候,我发现 Win7 这个扫雷涉及的细节太多了,有些特效实现根本无法下手,无法理解背后的逻辑是怎么做到的,只能有个模糊的感觉,整体上对这次的代码不是很满意,大概仿制了 80 % 左右吧,如果有机会继续完善,不过希望渺茫,因为仿制的烦了,太多细节需要处理,要一遍又一遍的研究原版程序,举个例子,你知道扫雷的雷数可以变为负数吗?那你知道这个负数最小是多少么?

标记最小值