【扫雷】游戏——功能扩展和部分逻辑优化

扫雷游戏系列目录

第一章 扫雷游戏——基础功能设计和代码实现【link
第二章 扫雷游戏——功能扩展和部分逻辑优化【本文】


前言

这里默认大家已经看过笔者的扫雷基础篇。链接: link
本篇代码早在24年1月初就已完成,但后续笔者有事外出了一段时间,导致两篇文章前后的发布时间相差较大,非常抱歉。
本文针对基础篇的扫雷代码,增加和完善了部分代码功能和逻辑,具体增改:

  1. 增加棋盘 递归展开 的功能
  2. 增加对地雷 标记和取消标记 的功能
  3. 增加 标记 的地雷 数量 提示功能
  4. 增加游戏的 时间统计 功能
  5. 重新设计 棋盘样式
  6. 改善扫雷游戏的 显示效果
  7. 提高代码的可读性

第一章 棋盘递归展开

1.1 递归

递归其实是一种解决问题的方法,递归就是函数自己调用自己。更近一步来讲,递归函数调用与其说是“调用该函数本身”,不如说是“调用和该函数同样的函数”,这样理解会更加自然。如果真的是调用函数本身,则会一直调用下去,进入死循环。
思想:把一个大型复杂的问题经过层层转化,将原问题拆解成一个与原问题相似,但规模较小的问题来求解,递归结束时也就代表原问题已经无法继续被拆分了。递归可以分为递推和回归两部分进行理解。
限制条件:书写递归时,需要保证递归能够结束,否则容易导致栈溢出的问题 。因此使用递归时要存在限制条件,并且每次的递归调用之后会逐步接近限制条件。
栈溢出(Stack overflow):C语言中的每一次函数调用,都要需要为本次函数调用在栈区申请一块内存空间来保存函数调用期间的各种局部变量的值,这块空间被称为运行时堆栈,或者函数栈帧。函数不返回,函数对应的栈帧空间就一直占用,所以如果函数调用中存在递归调用的话,每一次递归函数调用都会开辟属于自己的栈帧空间,直到函数递归不再继续,开始回归,才逐层释放栈帧空间。所以如果采用函数递归的方式完成代码,递归层次太深,就会浪费太多的栈帧空间,也可能引起栈溢出的问题。

练习:打印一个正整数的每一位数字,如输入数字123,则依次打印出123。
分析:123%10得到3,123/10得到12。12%10得到2,12/10得到1。1%10得到1。至此,通过相同的取模取商操作,可以得到一个数的每一位数字,当得到的数字是一个一位数时,就可以开始打印操作,继而打印出一个数字的每一位。因此,打印数字时的函数设计中,每次对参数的操作就是取模和取商,取模是为了打印数字,取商是为了得到下一个需要打印的数字。当参数小于10时,说明参数是一位数,开始打印,这也是递归的结束条件。递归的结束条件其实也是开始条件。如代码1-1所示。

//示例代码 1-1
#include <stdio.h>
void Print(unsigned n)
{
   if (n > 9)
   	Print(n/10);
   printf("%u", n % 10);
}
int main()
{
   unsigned n = 0;
   scanf("%d", &n);
   Print(n);
   return 0;
}

递归函数为Print(),递归时先递推到结束条件,然后开始回归并依次打印结果。对于输入的数字123,开始调用Print()。递推第一次,Print(123)中调用Print(),即调用Print(12),被调用的Print(12)中的代码开始执行,Print(123)最后的打印语句暂未执行,等待Print(12)调用结束。递推第二次,Print(123)中所调用的Print(12)暂未执行结束,Print(12)中再次调用Print(),即调用Print(1),调用的Print(1)中的代码开始执行,Print(12)最后的打印语句暂未执行,等待Print(1)调用结束。Print(1)中不满足递归条件,不再递推,直接执行打印语句得到数字1,Print()函数在第二次调用中执行结束,程序返回到【调用Print(1)语句的下一条语句】继续执行,递推结束,开始回归并依次执行调用Print()后的打印语句,得到每位数字。图1-2为函数具体调用过程,加深理解。

在这里插入图片描述图1-2 Print()函数的具体调用过程

1.2 代码设计

在进行递归展开时,首先要严格限制可以递归展开的棋盘范围是输入的棋盘横坐标 x >= 1 && x <= ROW,纵坐标 y >= 1 && y <= COL ,请读者自行思考不加坐标限制的影响。当输入的坐标位置在 mine 中不是地雷且周围八个位置都没有地雷时,就可以进行递归展开的操作,展开的位置采用空格展示。注意,输入的坐标处没有地雷时,无论周围八个位置有无地雷,是否进行递归展开操作,都算一次成功排雷,非雷元素的数量减一;当进行递归展开时,每一个被展开的位置都相当于被成功排查一次,因此也需要对判断输赢的参数 win 进行更新,这里采用指针 pwin 对 win 进行更新(另见指针 文章链接: link)。来看代码1-3。

//示例代码 1-3
//展开部分棋盘     
void ExpandShowBoard(const char mine[ROWS][COLS], char show[ROWS][COLS], int x, int y, int* pwin)
{
	if (x >= 1 && x <= ROW && y >= 1 && y <= COL)//限定递归在棋盘中允许展开的边界位置
	{
		int cnt = GetMine(mine, x, y);//统计地雷数量

		if (cnt)
		{
			show[x][y] = cnt + '0';
			(*pwin)++;
		}
		else
		{
			//递归  展开的元素改为空格
			int i = 0;

			(*pwin)++;
			show[x][y] = ' ';

			for (i = x - 1; i <= x + 1; i++)
			{
				int j = 0;

				for (j = y - 1; j <= y + 1; j++)
					if (show[i][j] == '*') //限定递归条件
						ExpandShowBoard(mine, show, i, j, pwin);
			}

		}
	}

}

第二章 标记和取消标记

标记和取消标记的功能,主要是为了方便玩家在游戏过程中 掌握 已排查区域的 地雷分布状况 。笔者在进行设计时,想要随时提醒玩家已经标记的地雷数量和可以使用的最大标记数量,而可使用的最大标记数量,笔者的设计是将其和分布的地雷数量保持一致,这也算是一种间接的游戏提醒。在进入代码设计部分前,来看下显示效果吧~

在这里插入图片描述
实现效果:
玩家每次在排查一个不是地雷的位置后,都会进入标记功能主界面

**********************************************
***1.标记地雷   2.取消标记   0.退出标记操作*****
**********************************************

当玩家标记一个未排查过的位置时,该位置的显示内容会从 * 更改为 & ,代表该位置处有地雷。当标记一个位置之后,能够进行 连续标记 的操作,当玩家不想再进行标记操作时,可以选择直接进行下一次排雷操作,或者回到标记功能主界面,进行取消标记的操作。

是否继续标记?【1.继续标记  2.回到标记功能主界面  3.直接进行下一次排雷操作】:

玩家回到标记功能主界面后,可以选择 “取消标记” 功能,将已经标记位置的 & 变更为 *,之后可以 连续取消标记 ,或者进行其它操作。

是否继续取消标记?【1.继续取消标记  2.回到标记功能主界面  3.直接进行下一次排雷操作】:

以上就是标记功能的大致效果,来看代码2-1。

//示例代码 2-1
//标记功能的主体函数设计
//标记和取消标记功能
void MarkShowBoard(char show[ROWS][COLS], int row, int col)
{
	int input = 0;
	int i = 0;
	int ConFlag = 0;
	int CancFlag = 0;
	
	do
	{
		MarkMenu();
		printf("\n\n请输入你的选择: ");
		scanf("%d", &input);

		switch (input)
		{
		case ConfMark:
			ConMarkShow(show, row, col, &ConFlag);
			if (1 == ConFlag)
				goto EndMark;
			else
				break;
		case CancMark:
			CancMarkShow(show, row, col, &CancFlag);
			if (1 == CancFlag)
				goto EndMark;
			else
				break;
		case ExitMark:
			puts("退出标记操作\n");
			break;
		default:
			puts("输入错误!请重新输入");
			break;
		}
	} while (input);

EndMark:
	{
		;
	}

}

笔者希望在每次排雷之后都能进行标记或取消标记,而且标记时能进行连续的多个位置标记,取消标记时同理,因此此处采用了 do…while() 的结构进行多次的循环判断处理,以便能连续进行多次操作。在每次进行标记或取消标记操作后,笔者希望有直接进行下一次排雷操作的选项,因此代码中加入了状态判断量 ConFlag 和 CancFlag, goto 语句便于直接跳转到函数末尾。进行标记/取消标记操作后,可以选择直接进行下一次排雷操作,或者继续标记/取消标记,也可以选择回到功能主界面更换选择,进行取消标记/标记的操作。

2.1 进行标记

标记时只能对 show 中显示 * 的位置操作,当对其它非法位置进行操作时会有相应的提示,同时,已被标记的位置无法直接进行排雷操作,需要取消地雷标记后才能对该位置进行排雷操作。pConFlag (指向主体函数中 ConFlag 的指针变量)是为了在连续标记时直接进入下一次排雷操作设置的一个状态参数,根据它的值可以判断玩家是否要在标记操作后直接进行排雷操作。当然也可以选择使用枚举类型,这里笔者选择了指针的方式,如代码2-2所示。(指针的相关知识请参考文章 链接: link

//示例代码 2-2
//标记坐标功能
static void ConMarkShow(char show[ROWS][COLS], int row, int col, int* pConFlag)
{
	int x = 0;
	int y = 0;
	int con = 0;

	do
	{
		*pConFlag = 0;
		
		system("cls");

		printf("\n【标记的地雷数量:%2d (可使用的标记数量:%d)】\n\n", CntMark, TolMark - CntMark);	//标记的数量信息
		DisPlayBoard(show, row, col);

		printf("\n请输入需要标记的地雷坐标: ");
		scanf("%d %d", &x, &y);

		if (x >= 1 && x <= row && y >= 1 && y <= col)//坐标合法
		{
			if (show[x][y] == '*')//能否标记
			{
				show[x][y] = '&';//标记
				CntMark++;

				system("cls");

				printf("\n【标记的地雷数量:%2d (可使用的标记数量:%d)】\n\n", CntMark, TolMark - CntMark);
				DisPlayBoard(show, row, col);

			}
			else if (show[x][y] == '&')
			{
				puts("该位置已被标记!请重新选择\n");
			}
			else
			{
				if (show[x][y] == '&')
					puts("该位置已被标记!无法重复标记。请重新选择\n");
				else if (show[x][y] == ' ')
					puts("该位置没有地雷!没有标记的必要。请重新选择\n");
				else
					puts("该位置不能标记!请重新选择\n");
			}
		}
		else
			puts("坐标错误!请重新输入\n");

		do//连续标记
		{
			printf("\n是否继续标记?【1.继续标记  2.回到标记功能主界面  3.直接进行下一次排雷操作】:  ");
			scanf("%d", &con);
			putchar('\n');
			if (1 == con || 2 == con || 3 == con)
				break;
			else
				puts("输入错误!请重新输入");
		} while (!(1 == con || 2 == con || 3 == con));

		if (2 == con)
			break;

		if (3 == con)
		{
			*pConFlag = 1;
			break;
		}

	} while (1 == con);
}

2.2 取消标记

取消标记的操作和进行标记的代码设计相似度较高。这里只提醒下取消标记的位置必须是 & 标记的位置,对其它非法位置进行取消标记的操作会有相应的警告出现,具体设计如代码2-3所示。

//示例代码 2-3
//取消标记功能
static void CancMarkShow(char show[ROWS][COLS], int row, int col, int* pCancFlag)
{
	int x = 0;
	int y = 0;
	int con = 0;

	do
	{
		*pCancFlag = 0;

		system("cls");

		printf("\n【标记的地雷数量:%2d (可使用的标记数量:%d)】\n\n", CntMark, TolMark - CntMark);//标记的数量信息
		DisPlayBoard(show, row, col);

		printf("\n请输入要取消标记的地雷坐标:");
		scanf("%d %d", &x, &y);

		if (x >= 1 && x <= row && y >= 1 && y <= col)//坐标有效
		{
			if (show[x][y] == '&')//只能取消 & 元素
			{
				show[x][y] = '*';
				CntMark--;

				system("cls");

				printf("\n【标记的地雷数量:%2d (可使用的标记数量:%d)】\n\n", CntMark, TolMark - CntMark);
				DisPlayBoard(show, row, col);
			}
			else
			{
				if (show[x][y] == '*')
					puts("未排查过的位置!该位置没有标记,无法取消!请重新输入\n");
				else
					puts("已排查过的位置!该位置没有标记,无法取消!请重新输入\n");
			}
		}
		else
			puts("输入错误!请重新输入\n");

		do//连续取消标记
		{
			printf("\n是否继续取消标记?【1.继续取消标记  2.回到标记功能主界面  3.直接进行下一次排雷操作】:  ");
			scanf("%d", &con);
			putchar('\n');
			if (1 == con || 2 == con || 3 == con)
				break;
			else
				puts("输入错误!请重新输入\n");
		} while (!(1 == con || 2 == con || 3 == con));

		if (2 == con)
			break;

		if (3 == con)
		{
			*pCancFlag = 1;
			break;
		}

	} while (1 == con);
}

第三章 标记的地雷数量提示

本次代码会一直在棋盘右上方显示已经标记的地雷数量和可以使用的标记数量。其中,可以使用的标记数量和布置的地雷数量一致。当玩家正确标记地雷位置后,只要将可以使用的标记数量用完,就可以轻松获得游戏胜利,但请玩家谨记,扫雷游戏是根据已经正确排查过的位置来进行输赢的判断,换言之,玩家必须将所有不是地雷的位置全部排查过后才能获得游戏胜利,仅仅标记出所有地雷的正确位置是不够的,请各位玩家记得走完最后一步。显示效果如图3-1所示。

在这里插入图片描述图3-1 标记的地雷数量显示

代码设计时,设置了两个全局变量 TolMark 和 CntMark。当每次标记一个地雷后,CntMark 会加一;当每次取消一个地雷标记时,CntMark 会减一,然后显示对应的参数值即可,如代码3-2所示。

//示例代码 3-2

//全局变量
int TolMark = NUM_LEI;			//可使用的最大标记数
int CntMark = 0;				//已使用的标记数量

//显示可使用的标记数量和已经标记的地雷数量
printf("\n【标记的地雷数量:%2d (可使用的标记数量:%d)】\n\n", CntMark, TolMark - CntMark);

//开始显示
void test(void)
{
	int input = 0;
	srand((unsigned int)time(NULL));
	do
	{
		TolMark = NUM_LEI;	//每局游戏都要重置
		CntMark = 0;
		GameMenu();
		//......
	}
}

第四章 游戏时间统计

这里用到了 difftime() 函数,用来获得结束时间 (endtime) 和开始时间 (begtime) 之间相差的秒数。来看下函数原型:
double difftime (time_t end, time_t beginning);
time_t 是能够表示时间的基本算术类型的别名,属于无符号整数类型。关于如何获得当前时间,可以参考笔者之前文章 link 中的【3.1 伪随机数和 rand() 、srand() 和 time()】,其中详细介绍了 time() 的用法。
结合difftime() 函数的特点,它可以得到开始时间和结束时间的差值,如果我们设定一个扫雷游戏开始的时间,只要一直更新停止计时的结束时间,那么就可以得到游戏时间,再将这个差值显示在屏幕,这样我们就有了游戏时间统计代码的设计思路,笔者将游戏时间统计功能加在了棋盘左上角的位置。如示例4-1所示。

//示例 4-1

//全局变量
time_t begtime = 0;
time_t endtime = 0;

//......

//显示游戏用时
begtime = time(NULL);												//获取开始时间
endtime = time(NULL);												//获取结束时间
printf("\n【游戏用时:%5.0fs】\t", difftime(endtime, begtime));		//打印游戏时间

示例4-1是针对游戏刚开始时,此时游戏用时为0。之后需要确定开始时间的获取位置和结束时间的更新位置,当玩家输入坐标进行排雷操作之前,也就是执行 FindMine() 函数时,就可以进行游戏开始时间的获取。开始统计游戏的时间点一旦确定,后续只需对结束时间进行更新。在想要显示游戏时间的位置处更新结束时间,再使用 difftime() 函数即可获得到从开始时间到这个时间点为止的时间间隔。如玩家游戏失败时,笔者想要显示游戏总用时,就有代码4-2。其它想要显示游戏用时的操作基本一致,不再赘述。

//示例代码 4-2
void FindMine(const char mine[ROWS][COLS], char show[ROWS][COLS], int row, int col)
{
	int x = 0;
	int y = 0;
	int win = 0;
	begtime = time(NULL);							//更新开始时间

	while (win < row * col - NUM_LEI)
	{
		printf("\n请输入排查的坐标: ");
		scanf("%d %d", &x, &y);

		if (x >= 1 && x <= row && y >= 1 && y <= col)//坐标合法
		{
			if (show[x][y] == '*')					//坐标重复
			{
				if (mine[x][y] == '1')				//直接死亡
				{
					system("cls");					//清屏

					endtime = time(NULL);			//更新结束时间

					printf("\n很遗憾!您踩中了地雷,本局游戏结束...\n");

					printf("\n【本局游戏总用时:%5.0fs】\t", difftime(endtime, begtime));//打印游戏时间
					//......
				}
			}
		}
	}				

在设计 begtime 和 endtime 这两个参数时,由于涉及到多处不同函数内部的使用,因此将其设计成了全局变量。涉及到全局变量的参数,建议的做法是在头文件中声明,在 .c 文件中定义。声明分为定义声明非定义声明。对于函数,定义声明就是详细设计了函数的具体实现方式和各种形参的声明等,平时说的”先定义后使用“中的定义,就是定义声明。非定义声明只是告诉编译器:我要使用这个函数,你在文件中其它地方找找这个函数具体的实现方式吧,而且文件中不一定有该函数的定义声明,因此非定义声明的函数,如果没有定义声明,直接使用时编译器肯定会报错。


第五章 棋盘样式重新设计

打印棋盘时,屏幕上显示的输出内容全部都是按行按顺序打印的,当然也可以选择提前换行 (\n),直接进入下一行进行打印。因此设计棋盘的显示代码时,只要按照数组的行和其对应的列,按顺序设计输出内容即可。

考虑到棋盘变大时,如果没有明显的分割线进行参照,即使棋盘中有行标和列标,查看每个元素在棋盘中对应的坐标也会显得比较吃力。因此重新设计棋盘的打印样式时,加入了分割线,把数组中的每个构成元素使用网格分割;显示棋盘行标和列标的数字使用不同的分割线,增加和数组构成元素间分割线的区分度。来看下三种不同的显示效果吧,如图5-1所示。棋盘均为 20 * 20。

显示效果01在这里插入图片描述

显示效果02在这里插入图片描述
显示效果03
在这里插入图片描述
图5-1 棋盘显示效果

笔者的显示屏为27寸,代码在命令行窗口运行时,整个屏幕能显示的最大显示范围为 29 * 38 的棋盘。如果棋盘过大,容易出现显示格式“失效”的情况,其实还是因为理论输出的棋盘太大,但实际显示用的屏幕空间太小,导致打印的内容出现提前被动换行的状况。不是代码没法显示,只是硬件设施不允许我正常显示啊~~~无敌的寂寞?来看代码5-2。

//示例代码 5-2 
//显示棋盘
void DisPlayBoard(const char arr[ROWS][COLS], int row, int col)
{
	int i = 0;
	int no = 0;

	打印棋盘01  >>扫雷游戏<<
	//for (i = 1; i <= ((5 * COL - 7) / 2); i++)
	//{
	//	if (1 == i)
	//	{
	//		printf(">>");
	//		continue;
	//	}
	//	printf(">");
	//}
	//printf("扫雷游戏");
	//for (i = 1; i <= ((5 * COL - 7) / 2); i++)
	//{
	//	if (1 == i)
	//	{
	//		printf("<");
	//		continue;
	//	}
	//	printf("<");
	//}
	//
	//putchar('\n');

	//for (i = 0; i <= col; i++)//打印列标
	//{
	//	if (0 == i)
	//	{
	//		printf("%d", i);
	//		continue;
	//	}
	//	printf("%5d", i);
	//}
	//putchar('\n');

	//for (i = 0; i < col; i++)//打印分隔线 ====
	//{
	//	if (0 == i)
	//	{
	//		printf("======");
	//		continue;
	//	}
	//	printf("=====");
	//}
	//putchar('\n');

	//for (i = 1; i <= row; i++)
	//{
	//	int j = 0;

	//	printf("%3d||", i);//打印行标

	//	for (j = 1; j <= col; j++)
	//	{
	//		if (1 == j)
	//		{
	//			printf("%c", arr[i][j]);
	//			continue;
	//		}
	//		printf("%5c", arr[i][j]);
	//	}
	//	putchar('\n');
	//}

	//打印棋盘02
	/*for (i = 0; i <= col; i++)
	{
		printf("%3d", i);
	}
	putchar('\n');

	for (i = 1; i <= row; i++)
	{
		int j = 0;

		printf("%3d", i);

		for (j = 1; j <= col; j++)
		{
			printf("%3c", arr[i][j]);
		}
		putchar('\n');
	}*/

	//打印棋盘03
	for (i = 1; i <= ((6 * COL - 3) / 2); i++)
	{
		if (1 == i)
		{
			printf(">>");
			continue;
		}
		printf(">");
	}
	printf("扫雷游戏");
	for (i = 1; i <= ((6 * COL - 3) / 2); i++)
	{
		printf("<");
	}
	putchar('\n');

	for (i = 0; i <= col; i++)		//打印列标
	{
		if (0 == i)
		{
			printf("%4d/", i);
			continue;
		}
		printf("%4d \\", i);
	}
	putchar('\n');

	for (i = 0; i <= col; i++)		//打印分隔线 +-+-
	{
		if (0 == i)
		{
			printf("   -+");
			continue;
		}
		printf("-+-+-+");
	}
	putchar('\n');

	for (i = 1; i <= row; i++)
	{
		int j = 0;

		printf("%4d/", i);			//打印行标

		for (j = 1; j <= col; j++)
			printf(" %3c |", arr[i][j]);
			
		putchar('\n');
		for (j = 0; j <= col; j++)	//打印分隔线 ====
		{
			if (0 == j)
			{
				printf("  ---");
				continue;
			}
			printf("------");
		}
		putchar('\n');
	}
}

第六章 改善显示效果

system(“cls”); 语句的作用是在Windows环境中 ,且在命令行窗口的状态下,用来清除屏幕目前显示的所有内容,注意只是视觉上清除内容,并不影响程序执行代码中的各种参数状态。如示例6-1。

需要打印的棋盘数据需要实时从显示缓冲区中获取数据并在命令行窗口进行显示,但程序执行过程中需要显示的数据要先经过处理,然后才会进入显示缓冲区,这会导致程序中需要显示的全部数据在屏幕上输出的时间有先后顺序,第一个打印的数据和最后一个打印的数据之间有时间差,这个显示完整图案的时间差在视觉上的表现就是屏幕出现闪烁。解决的方案是采用双缓冲技术,使用Windows的API接口,这里暂不做扩展,有兴趣的可以先自行查找资料了解。

//示例 6-1
//需要包含的头文件 <stdlib.h>
syetem("cls");	//清除命令行窗口中,该代码执行前的所有显示内容,后续显示的内容从窗口左上角(0,0)位置开始显示

第七章 代码的部分优化

7.1 const 类型修饰符

函数设计进行数组传参时,只是引用所接收的数组的元素值,而不改写的话,在声明接收数组的形参时就应该加上 const ,防止对数组内容的修改,如示例7-1所示。

//示例 7-1
//扫雷中的棋盘显示函数,只是将接收到的数组按照原样打印显示,并不需要修改其构成元素的值,因此可以使用 const 修饰数组
void DisPlayBoard(const char arr[ROWS][COLS], int row, int col);

如果使用 const 修饰变量,那么该变量会被修饰为常变量,本质还是变量,但该变量具有了常量的性质,不可被修改,如示例代码7-2所示。(另见const修饰指针 文章链接: link

//示例代码 7-2
#include <stdio.h>
int main()
{
	const int a = 7;
	a = 10;	//error  表达式必须是可修改的左值
	printf("%d\n", a);
	return 0;
}

7.2 枚举

枚举就是一个一个列举,将其所有可能的取值全部列出。如一周有七天,从周一到周七;一年的天数,365或366天等,如代码7-3所示。

//示例代码 7-3
//枚举的语法格式
//enum 枚举名 {枚举元素1,枚举元素2,......};
enum GameOption //创建一个枚举类型 enum GameOption ,该类型共包含两个枚举常量 ExitGame 和 PlayGame
{
	ExitGame, 	// 常量 0 的新名字
	PlayGame	// 常量 1 的新名字
};

可以看到,枚举常量之间使用逗号 “ , ” 分隔,所有的枚举常量使用大括号 “{ }”包围,并以分号 “ ; ” 结尾。ExitGame 和 PlayGame 看上去虽然是一串英文字母,但它们其实是常量,可以使用格式转换说明符 %d 打印出它们的值。没有指定值的枚举成员,第⼀个枚举成员的默认值为整型的0。 对于已指定值的枚举成员,后续枚举成员的值等于前⼀个成员的值加1 。当然也可以自行赋值,后续枚举成员的值会在重新赋值的枚举成员值的基础上,继续递增 1 ,但赋值的操作仅限在 C 语言中,C++中不行,C++ 的类型检查比较严格。如示例7-4所示。

//示例 7-4
enum Day//一周的七天
{
    Mon,	    //值为 0   默认 0
    Tues,	    //值为 1   在 Mon  = 0 的基础上递增 1
    Wed = 4,    //值为 4   重新赋值
    Thur,       //值为 5   在 Wed  = 4 的基础上递增 1
    Fri,        //值为 6   在 Thur = 5 的基础上递增 1
    Sat = 0,    //值为 0   重新赋值
    Sun         //值为 1   在 Sat  = 0 的基础上递增 1
};

枚举的加入,可以使用枚举常量来代替整型常量 0、1 等,还能一次定义多个常量。同时,枚举常量的名字本身就可以携带一部分信息,方便阅读代码和提高代码可读性。如代码7-5,定义了三个枚举常量后,在使用 switch…case 语句时,“case 1:” 可以替换为 “case ConfMark:”,功能一致,但看到枚举常量 ConfMark 可以方便联想到 Confirm Mark ,确认标记的意思,这就是代码可读性的提升。再回头看看, “case ConfMark:” 和 “case 1:” 这两种写法,你会选择哪个呢?

//示例代码 7-5
//代码段 01
enum MarkOption				//标记功能选项
{
	ExitMark,	//0
	ConfMark,	//1
	CancMark	//2
};

// 代码段 02
switch (input)
{
case ConfMark:	//等价 case 1:   确认标记
		break;
case CancMark:	//等价 case 2:	取消标记
		break;
case ExitMark:	//等价 case 0:	退出标记
	puts("退出标记操作\n");
	break;
default:
	puts("输入错误!请重新输入");
	break;
}

7.3 头文件防止重复包含

7.3.1 预编译指令

预编译(预处理)是对源文件进行预处理时的操作,也是源文件调试运行前最先开始进行的操作。预处理过程主要是对源文件中 # 开始的预编译指令进行处理,例如宏定义的展开、将 #include 的头文件插入到预编译指令的位置、删除所有注释等操作。

示例7-6中的两种预编译指令都是为了 防止 头文件被多次 重复 包含 (include)。二者最大的区别是 #ifndef … 这种形式受 C/C++ 语言标准支持;但 #pragma once 这种形式取决于编译器,并不是标准所支持的类型,因此是否能使用取决于读者使用的编译环境。集成开发环境 Visual Studio 2022 支持这种语法。

//示例 7-6
//01
#ifndef __GAME_H__	//如果没有定义 __GAME_H__(自己起名字)文件,则开始定义该文件,后边加上需要定义的内容,因此也叫条件编译指令。
#define __GAME_H__	//当所需文件中没有定义过__GAME_H__中所述的内容时,就会第一次进行编译,否则不再定义。可以用来解决包含头文件时,出现头文件重复包含和定义重复的相关问题
//后边加上声明定义和语句
//......
#endif				//#ifndef 的结束编译语句,结束编译。类似else必须和if成对出现的规则(不能说if必须和else成对出现,因为if可以单独使用,而else必须和if配套使用)

//02
#pragma once
//后边加上声明定义和语句
//......

7.4 extern 声明的全局变量

extern 声明的(全局)变量,可以在其它源文件中使用,函数同理(见代码7-11)。extern声明的全局变量如示例7-7所示。

//示例代码 7-7
//只介绍 extern声明的全局变量

//module_01.c 文件中定义一个全局变量ret
int ret = 5;

//test.c 文件中想用module_01.c中的全局变量ret,使用extern声明后即可使用
#include <stdio.h>
int main()
{
	extern ret;			//外部声明
	printf("%d\n", ret);//结果【5】
	ret = 7;			//也可以对该变量修改后,再次使用
	printf("%d\n", ret);//结果【7】
	return 0;
}

本次代码中设计了四个全局变量,但在进行全局变量的设计时,建议在 .c 文件中进行定义,在 .h 文件中对全局变量使用extern进行外部声明,告诉编译器这是一个在其它文件中定义的变量,代码编译后进行链接时,需要在该项目的其它文件中查找该变量对应的定义,如果找不到则链接失败并报错。这样设计方便对项目中的所有全局变量进行管理和使用,而且不会出现重复定义的错误。来看示例代码7-8。

//示例代码 7-8
//假设某项目有四个文件,分别是 test.c  test.h  module_01.c  module_02.c
//test.h								//test.c
#include <stdio.h>						#include "test.h"
int a1 = 0;								int main()
int a2 = 0;								{
void A1(void); 								A1();
void A2(void);								A2();
											printf("%d\n", a1 + a2);
											return 0;
										}
											
//module_01.c							//module_02.c
#include "test.h"						#include "test.h"
void A1(void)							void A2(void)
{										{
	a1 = 1;									a2 = 3;
	printf("%d\n", a1);						printf("%d\n", a2);
}										}					

示例代码7-8在进行编译时,会有错误出现:fatal error LNK1169: 找到一个或多个多重定义的符号。报错的原因是对变量进行了多次重定义,因为该项目中的其它三个文件都包含了 test.h 文件,代码在进行预处理时,会将文件中 #include 的文件全部展开,也就是 test.h 文件的内容,在其它三个文件中都出现一次,每次展开内容时,int a1 = 0; int a2 = 0; 这两条语句都会出现一次,而项目中的代码文件最终是会被整合到一个代码文件中进行链接处理等,因此会出现同一个变量有多次定义声明出现的情况,这肯定会报错导致运行失败。按照之前的设计思路,将代码进行修改,如7-9所示,这样就可以完美运行。

//示例代码 7-9
//修改后
//test.h								//test.c
#include <stdio.h>						#include "test.h"
extern int a1;//a1的外部声明				int main()
extern int a2;//a2的外部声明				{
void A1(void); 								A1();					//结果【1】
void A2(void);								A2();					//结果【2】
											a1 = 2;					//包含test.h后,直接使用其它文件中的全局变量	
											a2 = 3;					
											printf("%d\n", a1 + a2);//结果【5】
											return 0;
										}
											
//module_01.c							//module_02.c
#include "test.h"						#include "test.h"
int a1 = 0;		//a1的定义声明			int a2 = 0;		//a2的定义声明
void A1(void)	//a1在该文件中创建		void A2(void)	//a2在该文件中创建
{										{
	a1 = 1;									a2 = 2;
	printf("%d\n", a1);						printf("%d\n", a2);
}										}					

7.5 static 的使用

7.5.1 修饰变量

static 修饰的变量具有静态存储期,拥有和全局变量相同的生命周期。如示例代码7-10,在调用test()时,其内部创建的局部变量val使用static修饰,val的作用范围不变,但生命周期被延长,存在时间是从程序开始运行到程序结束。因此每次调用test()时都会有不同的结果,第三次调用test()打印val的值为5,打印后val的值变为6。如果不用static修饰val,那么每次调用test()的结果都会是3。

//示例代码 7-10
//static 修饰变量
#include <stdio.h>
void test(void)
{
	static int val = 3;
	printf("%d\n", val++);
}
int main()
{
	test();		//结果【3】
	test();		//结果【4】
	test();		//结果【5】
	return 0;
}

7.5.2 修饰函数

static修饰的函数只能在该文件内使用,其它文件的函数无法对其调用。如示例代码7-11。

//示例代码 7-11
//static 修饰函数
//假设有两个文件 main.c 和 Add.c
//main.c												//Add.c
#include <stdio.h>										int Add(int x, int y)//若写为 static int Add(int x, int y)...
int main()												{					//则 main.c中无法调用Add(),Add()只能被Add.c文件中的其它函数调用
{															return x + y;	//static修饰的函数只能在该文件内部使用
	extern int Add(int, int);//extern声明外部函数		}
	int ret = Add(2, 3);								//......
	printf("%d\n", ret);
	return 0;
}

第八章 源代码分享

8.1 头文件 game.h

#ifndef __GAME_H__
#define __GAME_H__

#define _CRT_SECURE_NO_WARNINGS 1 //消除函数使用不安全的警告

#include <stdio.h>
#include <stdlib.h>
#include <time.h>

#define ROW 20					//棋盘行数
#define COL 20					//棋盘列数

#define ROWS ROW + 2
#define COLS COL + 2

#define NUM_LEI 11				//地雷数量

extern int TolMark;				//可使用的最大标记数
extern int CntMark;				//已使用的标记数量

extern time_t begtime;			//游戏开始时间
extern time_t endtime;			//游戏结束时间

enum GameOption					//游戏菜单主界面选项
{
	ExitGame,
	PlayGame
};

enum MarkOption					//标记功能选项
{
	ExitMark,
	ConfMark,
	CancMark
};

//初始化
void InitBoard(char arr[ROWS][COLS], int rows, int cols, char set);

//随机布雷
void SetMine(char mine[ROWS][COLS], int row, int col);

//显示
void DisPlayBoard(const char arr[ROWS][COLS], int row, int col);

//排雷
void FindMine(const char mine[ROWS][COLS], char show[ROWS][COLS], int row, int col);

//标记
void MarkShowBoard(char show[ROWS][COLS], int row, int col);

//展开部分棋盘
void ExpandShowBoard(const char mine[ROWS][COLS], char show[ROWS][COLS], int x, int y, int* pwin);

#endif

8.2 主函数 test.c

#include "game.h"

int TolMark = NUM_LEI;			//可使用的最大标记数
int CntMark = 0;				//已使用的标记数量

time_t begtime = 0;
time_t endtime = 0;

void GameMenu(void)
{
	puts("*********************************************************");
	puts("***                    游戏即将开始                   ***");
	puts("***          1.play                  0.exit           ***");
	puts("*********************************************************");
	puts("***  Tips 1: 连续将所有无雷区域排查后即可获得游戏胜利  **");
	puts("***  Tips 2: 可使用的标记数量等于地雷数量	      ***");
	puts("*********************************************************");

}

void game(void)
{
	//定义两个数组
	char mine[ROWS][COLS] = { 0 };
	char show[ROWS][COLS] = { 0 };

	//初始化
	InitBoard(mine, ROWS, COLS, '0');
	InitBoard(show, ROWS, COLS, '*');

	//随机布雷
	SetMine(mine, ROW, COL);
	//DisPlayBoard(mine, ROW, COL);

	//显示游戏用时
	begtime = time(NULL);																//获取开始时间
	endtime = time(NULL);																//获取结束时间
	printf("\n【游戏用时:%5.0fs】\t", difftime(endtime, begtime));						//打印游戏时间
	printf("【标记的地雷数量:%2d (可使用的标记数量:%d)】\n\n", CntMark, TolMark - CntMark);//标记的数量信息

	//显示初始棋盘
	DisPlayBoard(show, ROW, COL);

	//排雷
	FindMine(mine, show, ROW, COL);
}

void test(void)
{
	int input = 0;
	srand((unsigned int)time(NULL));

	do
	{
		TolMark = NUM_LEI;			//每局游戏都要重置
		CntMark = 0;				

		GameMenu();

		printf("请选择:");
		scanf("%d", &input);

		switch (input)
		{
		case PlayGame:
			system("cls");	//清屏
			game();
			break;
		case ExitGame:
			printf("\n退出游戏\n");
			break;
		default:
			printf("\n输入错误,重新选择\n");
			break;
		}
	} while (input);

}

int main(void)
{
	test();

	return 0;
}

8.3 函数定义 game.c

#include "game.h"
//初始化
void InitBoard(char arr[ROWS][COLS], int rows, int cols, char set)
{
	int i = 0;

	for (i = 0; i < rows; i++)
	{
		int j = 0;

		for (j = 0; j < cols; j++)
			arr[i][j] = set;
	}
}

//随机布雷
void SetMine(char mine[ROWS][COLS], int row, int col)
{
	int cnt = 0;

	while (cnt < NUM_LEI)
	{
		int x = rand() % row + 1;
		int y = rand() % col + 1;

		if (mine[x][y] == '0')
		{
			mine[x][y] = '1';
			cnt++;
		}
	}
}

//显示
void DisPlayBoard(const char arr[ROWS][COLS], int row, int col)
{
	int i = 0;
	int no = 0;

	for (i = 1; i <= ((6 * COL - 3) / 2); i++)
	{
		if (1 == i)
		{
			printf(">>");
			continue;
		}
		printf(">");
	}
	printf("扫雷游戏");
	for (i = 1; i <= ((6 * COL - 3) / 2); i++)
		printf("<");
	putchar('\n');

	for (i = 0; i <= col; i++)//打印列标
	{
		if (0 == i)
		{
			printf("%4d/", i);
			continue;
		}
		printf("%4d \\", i);
	}
	putchar('\n');

	for (i = 0; i <= col; i++)//打印分隔线 +-+-
	{
		if (0 == i)
		{
			printf("   -+");
			continue;
		}
		printf("-+-+-+");
	}
	putchar('\n');

	for (i = 1; i <= row; i++)
	{
		int j = 0;

		printf("%4d/", i);//打印行标

		for (j = 1; j <= col; j++)
			printf(" %3c |", arr[i][j]);
		putchar('\n');
		for (j = 0; j <= col; j++)//打印分隔线 ====
		{
			if (0 == j)
			{
				printf("  ---");
				continue;
			}
			printf("------");
		}
		putchar('\n');
	}
}

//得到地雷数量
static int GetMine(const char mine[ROWS][COLS], const int x, const int y)
{
	int i = 0;
	int cnt = 0;

	for (i = x - 1; i <= x + 1; i++)
	{
		int j = 0;

		for (j = y - 1; j <= y + 1; j++)
			cnt += (mine[i][j] - '0');
	}
	return cnt;
}

void MarkMenu(void)
{
	puts("**********************************************");
	puts("***1.标记地雷   2.取消标记   0.退出标记操作***");
	puts("**********************************************");

}

static void ConMarkShow(char show[ROWS][COLS], int row, int col, int* pConFlag)
{
	int x = 0;
	int y = 0;
	int con = 0;

	do
	{
		*pConFlag = 0;
		
		system("cls");
		
		printf("\n【标记的地雷数量:%2d (可使用的标记数量:%d)】\n\n", CntMark, TolMark - CntMark);	//标记的数量信息
		DisPlayBoard(show, row, col);

		printf("\n请输入需要标记的地雷坐标: ");
		scanf("%d %d", &x, &y);

		if (x >= 1 && x <= row && y >= 1 && y <= col)//坐标合法
		{
			if (show[x][y] == '*')//能否标记
			{
				show[x][y] = '&';//标记
				CntMark++;

				system("cls");

				printf("\n【标记的地雷数量:%2d (可使用的标记数量:%d)】\n\n", CntMark, TolMark - CntMark);
				DisPlayBoard(show, row, col);

			}
			else if (show[x][y] == '&')
				puts("该位置已被标记!请重新选择\n");
			else
			{
				if (show[x][y] == '&')
					puts("该位置已被标记!无法重复标记。请重新选择\n");
				else if (show[x][y] == ' ')
					puts("该位置没有地雷!没有标记的必要。请重新选择\n");
				else
					puts("该位置不能标记!请重新选择\n");
			}
		}
		else
			puts("坐标错误!请重新输入\n");

		do//连续标记
		{
			printf("\n是否继续标记?【1.继续标记  2.回到标记功能主界面  3.直接进行下一次排雷操作】:  ");
			scanf("%d", &con);
			putchar('\n');
			if (1 == con || 2 == con || 3 == con)
				break;
			else
				puts("输入错误!请重新输入");
		} while (!(1 == con || 2 == con || 3 == con));

		if (2 == con)
			break;

		if (3 == con)
		{
			*pConFlag = 1;
			break;
		}

	} while (1 == con);
}

static void CancMarkShow(char show[ROWS][COLS], int row, int col, int* pCancFlag)
{
	int x = 0;
	int y = 0;
	int con = 0;

	do
	{
		*pCancFlag = 0;

		system("cls");

		printf("\n【标记的地雷数量:%2d (可使用的标记数量:%d)】\n\n", CntMark, TolMark - CntMark);//标记的数量信息
		DisPlayBoard(show, row, col);

		printf("\n请输入要取消标记的地雷坐标:");
		scanf("%d %d", &x, &y);

		if (x >= 1 && x <= row && y >= 1 && y <= col)//坐标有效
		{
			if (show[x][y] == '&')//只能取消 & 元素
			{
				show[x][y] = '*';
				CntMark--;

				system("cls");

				printf("\n【标记的地雷数量:%2d (可使用的标记数量:%d)】\n\n", CntMark, TolMark - CntMark);
				DisPlayBoard(show, row, col);
			}
			else
			{
				if (show[x][y] == '*')
					puts("未排查过的位置!该位置没有标记,无法取消!请重新输入\n");
				else
					puts("已排查过的位置!该位置没有标记,无法取消!请重新输入\n");
			}
		}
		else
			puts("输入错误!请重新输入\n");

		do//连续取消标记
		{
			printf("\n是否继续取消标记?【1.继续取消标记  2.回到标记功能主界面  3.直接进行下一次排雷操作】:  ");
			scanf("%d", &con);
			putchar('\n');
			if (1 == con || 2 == con || 3 == con)
				break;
			else
				puts("输入错误!请重新输入\n");
		} while (!(1 == con || 2 == con || 3 == con));

		if (2 == con)
			break;

		if (3 == con)
		{
			*pCancFlag = 1;
			break;
		}

	} while (1 == con);
}

//标记、取消标记
void MarkShowBoard(char show[ROWS][COLS], int row, int col)
{
	int input = 0;
	int i = 0;
	int ConFlag = 0;
	int CancFlag = 0;

	do
	{
		MarkMenu();
		printf("\n\n请输入你的选择: ");
		scanf("%d", &input);

		switch (input)
		{
		case ConfMark:
			ConMarkShow(show, row, col, &ConFlag);
			if (1 == ConFlag)
				goto EndMark;
			else
				break;
		case CancMark:
			CancMarkShow(show, row, col, &CancFlag);
			if (1 == CancFlag)
				goto EndMark;
			else
				break;
		case ExitMark:
			puts("退出标记操作\n");
			break;
		default:
			puts("输入错误!请重新输入");
			break;
		}
	} while (input);

EndMark:
	{
		;
	}

}

//展开部分棋盘     
void ExpandShowBoard(const char mine[ROWS][COLS], char show[ROWS][COLS], int x, int y, int* pwin)
{
	if (x >= 1 && x <= ROW && y >= 1 && y <= COL)
	{
		int cnt = GetMine(mine, x, y);

		if (cnt)
		{
			show[x][y] = cnt + '0';
			(*pwin)++;
		}
		else
		{
			int i = 0;

			(*pwin)++;
			show[x][y] = ' ';

			for (i = x - 1; i <= x + 1; i++)
			{
				int j = 0;

				for (j = y - 1; j <= y + 1; j++)
					if (show[i][j] == '*') 
						ExpandShowBoard(mine, show, i, j, pwin);

			}

		}
	}

}

//排雷
void FindMine(const char mine[ROWS][COLS], char show[ROWS][COLS], int row, int col)
{
	int x = 0;
	int y = 0;
	int win = 0;
	begtime = time(NULL);	//更新开始时间

	while (win < row * col - NUM_LEI)
	{
		printf("\n请输入排查的坐标: ");
		scanf("%d %d", &x, &y);

		if (x >= 1 && x <= row && y >= 1 && y <= col)//坐标合法
		{
			if (show[x][y] == '*')					//坐标重复
			{
				if (mine[x][y] == '1')				//直接死亡
				{
					system("cls");
					endtime = time(NULL);			//更新结束时间

					printf("\n很遗憾!您踩中了地雷,本局游戏结束...\n");

					printf("\n【本局游戏总用时:%5.0fs】\t", difftime(endtime, begtime));					//打印游戏时间
					printf("【标记的地雷数量:%2d (可使用的标记数量:%d)】\n\n", CntMark, TolMark - CntMark);//标记的数量信息

					puts("来看下本次的地雷分布吧!");
					DisPlayBoard(mine, row, col);
					putchar('\n');
					break;
				}
				else
				{
					ExpandShowBoard(mine, show, x, y, &win);//展开部分棋盘

					system("cls");//清屏

					endtime = time(NULL);//更新结束时间
					printf("\n【游戏用时:%5.0fs】\t", difftime(endtime, begtime));

					printf("【标记的地雷数量:%2d (可使用的标记数量:%d)】\n\n", CntMark, TolMark - CntMark);

					DisPlayBoard(show, row, col);
					putchar('\n');

					if (win == ROW * COL - NUM_LEI)//获胜
					{
						puts("\a恭喜你!排雷成功,你真的厉害!!!^ - ^\n");

						endtime = time(NULL);//更新结束时间
						printf("【本局游戏用时:%5.0fs】\t", difftime(endtime, begtime));
						printf("【标记的地雷数量:%2d (可使用的标记数量:%d)】\n\n", CntMark, TolMark - CntMark);

						break;
					}

					MarkShowBoard(show, row, col);//标记地雷

					system("cls");
					
					endtime = time(NULL);																 //更新结束时间
					printf("\n【游戏用时:%5.0fs】\t", difftime(endtime, begtime));						 
					printf("【标记的地雷数量:%2d (可使用的标记数量:%d)】\t\n\n", CntMark, TolMark - CntMark);


					DisPlayBoard(show, row, col);
				}
			}
			else
			{
				if (show[x][y] == '&')
					printf("该坐标已被标记!无法排查。请重新选择\n");
				else if (show[x][y] == ' ')
					printf("该位置没有地雷!无需排查。请重新选择\n");
				else
					printf("该位置已被排查过!请重新选择\n");
			}
		}
		else
			puts("坐标错误!请重新输入");
	}
}

总结

本文主要是对之前扫雷代码的部分功能添加和优化,扫雷的基础功能都已实现,其它的功能添加和优化,读者可以根据所学自行探索,扫雷代码设计暂时完结,感谢阅读。

  • 19
    点赞
  • 22
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值