C/C++控制台扫雷实现与详解(可移动光标版本)

C/C++控制台扫雷实现与详解

  • 一 目标
  • 二 功能描述
  • 三 运行结果
  • 四 代码结构
    • 1. 数据结构
      • 1.1. 格子标志枚举
      • 1.2. 格子结构体
    • 2. 地图管理
    • 3. 游戏主循环
      • 3.1. 关键变量
      • 3.2. 主循环体
        • 3.2.1. 输入地图信息
        • 3.2.2. 初始化地图
        • 3.2.3. 生成地雷
        • 3.2.4. 绘制地图
        • 3.2.5. 处理玩家的键盘输入
          • 光标控制
          • 键盘输入
    • 4. 玩家操作
      • 关键宏:遍历邻居格子
      • 关键函数:获取周围地雷数
      • 4.1. 点开格子
      • 4.2. 自动点开邻居
      • 4.3. 标记格子
  • 五 完整代码

一 目标

实现一个控制台扫雷,玩家可以通过移动控制台光标来点开格子或者标记格子。

二 功能描述

  • 提供默认地图
  • 可以自定义地图
  • 通过WASD键移动光标在地图上的位置
  • 通过J键模拟鼠标左键,点开光标所在格子
    • 如果格子有地雷,则游戏失败,可以选择重新开始
    • 如果格子没有地雷,则显示周围地雷数
    • 如果格子周围没有地雷,则递归点开周围格子
  • 通过K键模拟鼠标中键,如果光标所在格子周围地雷数和标记的旗子数相同,那么可以自动点开周围剩余未点开的格子
  • 通过L键模拟鼠标右键,给格子增加/删去旗子标志,旗子标志是’@'符号
  • 通过I键给格子增加/删去问号标志,即’?'符号
  • 可以避免玩家一开始就踩到地雷
  • 失败检测:检测到点开了有地雷的格子,游戏失败
  • 胜利检测:检测到所有没有地雷的格子都点开了,所有有地雷的格子都标记了旗子,游戏胜利

三 运行结果

C/C++控制台扫雷实现与详解(可移动光标版本)_第1张图片

四 代码结构

1. 数据结构

1.1. 格子标志枚举

原版扫雷的格子上一共有三种标志,数字 旗子和问号。
数字标志从1~9,如果是0的话则显示空格。因为地图一般大部分是0,显示0的话不好看,原版的扫雷也没有显示0。
旗子用来表示玩家认为这个格子上有地雷。这里使用’@‘符号来表示旗子。
问号用来表示玩家不确定这个格子上是否有地雷。这里使用’?'符号来表示问号。

enum cell_flag
{
	// 什么标志都没有
	cf_none,
	// 数字标志,显示周围雷的个数,格子被点开时自动被标记
	cf_number,
	// 旗子标志,标记你认为有地雷的格子
	cf_flag,
	// 问号标志,标记你不确定是否有雷的格子
	cf_qmark
};

1.2. 格子结构体

一个格子除了可能有标志以外,还需要有一个bool变量来存储这个格子上是否有地雷。

struct cell
{
	// 这个格子有没有地雷
	bool has_mine;
	// 这个格子的标志
	enum cell_flag flag;
};

2. 地图管理

首先是最大/最小值管理,游戏地图最大行数不超过20,最大列数不超过40,最小行数和列数不小于2。
其次是默认值管理,因为如果自定义地图的话,地图的大小以及地雷数就要设计得比较好才会好玩。如果地雷太少就会很容易,且地图大部分都是空的;太多就会很难。所以提供一个默认地图,让玩家可以不用自定义地图就能开玩。默认值是参考原版扫雷的高级模式。这里也可以把代码改成提供初级 中级和高级三种模式给玩家选择。
最后,使用current_rowscurrent_cols来存储当前地图的行列数,使用num_mines来存储当前地图中的地雷数。定义一个struct cell类型的二维数组来表示地图。

// 地图最大行数
#define MAX_ROWS 20
// 地图最大列数
#define MAX_COLS 40
// 地图最小行数
#define MIN_ROWS 2
// 地图最小列数
#define MIN_COLS 2
// 默认地图行数
#define DEFAULT_ROWS 16
// 默认地图列数
#define DEFAULT_COLS 30
// 默认地图中地雷数
#define DEFAULT_MINES 99
// 当前地图行数
int current_rows = DEFAULT_ROWS;
// 当前地图列数
int current_cols = DEFAULT_COLS;
// 地雷个数
int num_mines = DEFAULT_MINES;
// 地图
struct cell map[MAX_ROWS][MAX_COLS];

3. 游戏主循环

3.1. 关键变量

使用is_first_try来存储是否是第一次点开格子,用于后续避免第一次点开就踩到地雷的情况。
使用is_game_over来存储游戏是否失败。用于结束处理玩家键盘输入的循环。

// 是不是第一次打开格子
// 用来防止第一下就踩雷
bool is_first_try = true;
// 游戏是否结束
bool is_game_over = false;

3.2. 主循环体

首先,清空控制台,因为如果是第二局游戏的话,控制台上会有上一局游戏的内容。
然后,由玩家输入自定义地图信息,或者选择使用默认地图。
其次,根据地图信息,即地图的行列数和地雷数,初始化地图。
其次,随机抽取地图上的格子,生成地雷。
其次,在控制台上绘制地图。
其次,打印游戏的帮助信息,包括如何操作,以及地图信息。
最后,循环接收玩家输入,执行输入相对应的操作。

int main()
{
	// 游戏主循环
	while (true)
	{
		// 清空控制台
		system("cls");
		// 输入地图信息
		input_map();
		// 初始化地图
		init_map();
		// 生成地雷
		generate_mines();
		// 绘制地图
		draw_map();
		// 输出帮助信息
		print_help();
		// 处理玩家的键盘输入
		handle_player_input();
	}
}

3.2.1. 输入地图信息

首先我们需要一个tmp_buf变量来接收输入错误后剩余的字符串,或者正常输入后的回车字符。因为使用scanf输入之后,可能会有残留的字符串(输入错误的情况下)或者回车键在输入缓冲区中,不进行处理的话后续的输入(第二局游戏的时候)会出现错误。
这个函数主要是让玩家选择是否使用默认地图,或者自定义地图。
如果自定义地图,则输入地图行列数以及地雷数。如果输入格式错误,比如应当输入数字却输入字母,则提示重新输入。
同时还会检测输入的地图信息是否合理,即输入的行列数不能超过最大值 小于最小值,以及至少有一个地雷,不能整个地图都是地雷。

void input_map()
{
	// 用来接收输入错误后剩余的字符串,或者正常输入后的回车字符
	char tmp_buf[256] = { 0 };
	printf("默认地图:16行30列,99颗地雷\n");
	printf("使用默认地图(y/n)?:");
	char ch = 0;
	// 判断是否使用默认地图
	if (scanf("%c", &ch) == 1 && (ch == 'y' || ch == 'Y'))
	{
		// 设置默认行数 列数和地雷数
		current_rows = DEFAULT_ROWS;
		current_cols = DEFAULT_COLS;
		num_mines = DEFAULT_MINES;
		// 接收多余字符(回车)
		fgets(tmp_buf, sizeof(tmp_buf), stdin);
		return;
	}
	// 接收多余字符(回车)
	fgets(tmp_buf, sizeof(tmp_buf), stdin);
	// 输入地图大小
	printf("请输入地图大小:");
	while (scanf("%d%d", &current_rows, &current_cols) != 2)
	{
		// 输入错误,重新输入
		fprintf(stderr, "输入地图大小格式错误!\n");
		// 接收多余字符
		fgets(tmp_buf, sizeof(tmp_buf), stdin);
		printf("请重新输入:");
	}
	fgets(tmp_buf, sizeof(tmp_buf), stdin);
	// 检查并更正地图大小
	if (current_rows < MIN_ROWS)current_rows = MIN_ROWS;
	else if (current_rows > MAX_ROWS) current_rows = MAX_ROWS;
	if (current_cols < MIN_COLS)current_cols = MIN_COLS;
	else if (current_cols > MAX_COLS)current_cols = MAX_COLS;
	// 输入地雷个数
	printf("请输入地雷个数:");
	while (scanf("%d", &num_mines) != 1)
	{
		// 输入错误,重新输入
		fprintf(stderr, "输入地雷个数格式错误!\n");
		// 接收多余字符
		fgets(tmp_buf, sizeof(tmp_buf), stdin);
		printf("请重新输入:");
	}
	// 检查并更正地雷个数
	int max_mines = current_rows * current_cols - 1;
	if (num_mines <= 0)num_mines = 1;
	else if (num_mines >= max_mines) num_mines = max_mines;
	// 接收多余字符(回车)
	fgets(tmp_buf, sizeof(tmp_buf), stdin);
}

3.2.2. 初始化地图

这个函数首先会将游戏的关键变量is_first_tryis_gmae_over重置为初始值,然后会遍历每一个格子,然后将格子的地雷和标志清空。

void init_map()
{
	// 下一次点开格子是第一次点开格子
	is_first_try = true;
	// 游戏结束标志
	is_game_over = false;
	// 遍历每一行
	for (int i = 0; i < current_rows; ++i)
	{
		// 遍历每一列
		for (int j = 0; j < current_cols; ++j)
		{
			// 清空地雷
			map[i][j].has_mine = false;
			// 清空标志
			map[i][j].flag = cf_none;
		}
	}
}

3.2.3. 生成地雷

这个函数首先会先设置随机数种子,避免每一次游戏地图都一样。
然后会进入一个while循环,循环体中每次随机取一个格子,如果格子没有地雷就生成地雷,如果已经有地雷了,就重来,直到没有地雷需要生成了。这个生成算法还可以进行优化的。

void generate_mines()
{
	// 设置随机数种子
	srand(time(0));
	// 每次生成一个地雷
	// 直到待生成的地雷数为0
	int mines_to_generate = num_mines;
	while (mines_to_generate > 0)
	{
		// 随机取地图的一行
		int row = rand() % current_rows;
		// 随机取地图的一列
		int col = rand() % current_cols;
		// 获取该行该列的格子
		struct cell* cell = &map[row][col];
		// 如果这个格子已经有地雷了
		if (cell->has_mine)
		{
			// 那么就跳过这个格子,重新找
			continue;
		}
		// 否则的话
		else
		{
			// 生成地雷
			cell->has_mine = true;
			// 并将待生成的地雷数减一
			mines_to_generate--;
		}
	}
}

3.2.4. 绘制地图

这个函数会先清空控制台,即清除之前输入地图信息时的输入,然后遍历每一个格子,打印格子符号,初始都是没有任何标志的符号,即’*'号。同时也会打印地图边界,因为周围没有地雷时的数字是空格不是0,所以如果没有边界的话,当边缘周围没有地雷时看上去会不好看。

void draw_map()
{
	// 清空控制台
	system("cls");
	// 遍历每一行
	for (int i = 0; i < current_rows; ++i)
	{
		// 遍历每一列
		for (int j = 0; j < current_cols; ++j)
		{
			// *表示一个没有任何标志的格子
			printf("*");
			// *后面跟一个空格保持地图美观
			// 保证行数和列数相等时是个正方形
			if (j != current_cols - 1)printf(" ");
		}
		// 打印右边界并且换行
		printf("|\n");
	}
	// 打印底边界
	for (int i = 0; i < current_cols; ++i)
		printf("--");
	// 删掉最后一个'-'并增加边界然后换行
	printf("\b|\n");
}

3.2.5. 处理玩家的键盘输入

光标控制

首先我们需要先控制光标在控制台上的位置。这里我们使用Win32提供的相关接口GetConsoleScreenBufferInfo以及SetConsoleCursorPosition来获取/设置光标的位置。get_cursor_xyset_cursor_xy用来直接获取/设置光标在控制台上的位置。set_cursor_rc用来将光标移动到指定的地图行列位置。因为之前为了地图美观,每一个’*'号之后都跟着空格,所以计算x时需要将列数乘以2。print_at_rc用来在指定的地图行列位置输出一个字符,同时保持光标位置。因为printf会改变光标位置,所以需要在printf之后恢复光标位置。

// 获取光标所在位置的x y值
void get_cursor_xy(int* x, int* y)
{
	HANDLE handle = GetStdHandle(STD_OUTPUT_HANDLE);
	CONSOLE_SCREEN_BUFFER_INFO csbi;
	GetConsoleScreenBufferInfo(handle, &csbi);
	*x = csbi.dwCursorPosition.X;
	*y = csbi.dwCursorPosition.Y;
}

// 将光标移至位置控制台(x, y)处
void set_cursor_xy(int x, int y)
{
	HANDLE handle = GetStdHandle(STD_OUTPUT_HANDLE);
	COORD coord = { x, y };
	SetConsoleCursorPosition(handle, coord);
}

// 将光标移至地图的第row行的第col列
void set_cursor_rc(int row, int col)
{
	HANDLE handle = GetStdHandle(STD_OUTPUT_HANDLE);
	COORD coord = {col * 2, row};
	SetConsoleCursorPosition(handle, coord);
}

// 在地图的第row行的第col列打印字符ch
void print_at_rc(char ch, int row, int col)
{
	set_cursor_rc(row, col);
	printf("%c", ch);
	set_cursor_rc(row, col);
}
键盘输入

首先需要先保存一开始的光标位置。因为一开始的时候光标应该是在输出的最后位置。
然后将光标移到地图的第一个格子,之后进入循环。
循环条件是游戏没有失败,如果踩到地雷,则游戏失败,结束循环。
循环体内通过_getch()来得到输入字符,使用这个函数是因为这个函数没有回显。
拿得输入字符后,对字符进行判断。
ESC键的数值是0x1b,如果是ESC键的话则直接返回。直接返回即可重新开始。
如果是wasd键,则修改row或者col变量,然后还需要检测范围,将光标限制在地图之内,再调用set_cursor_rc来移动光标至对应格子。
如果是jkli键,则执行对应操作:点开格子/自动点开邻居/进行标记。
在处理完操作后,判断是否满足胜利条件,胜利条件是所有没有地雷的格子都点开了,所有有地雷的格子都标记成旗子。
胜利或者失败都会结束循环,然后将光标恢复到最后,打印结束语,询问是否再来一局。

// 接受玩家的键盘输入,并进行处理
void handle_player_input()
{
	// 先获取光标当前的x y值
	// 便于游戏结束时在最后位置输出信息
	int x, y;
	get_cursor_xy(&x, &y);
	// 将光标移至控制台左上角
	// 即地图的第一个格子
	set_cursor_rc(0, 0);
	int row = 0, col = 0;
	// 循环直到踩到地雷或者胜利
	while (!is_game_over)
	{
		int ch = _getch();
		// 0x1b表示ESC键
		// 重新开始
		if (ch == 0x1b)
		{
			return;
		}
		else
		{
			switch (ch)
			{
			case 'w':// 上移
				--row;
				break;
			case 'a':// 左移
				--col;
				break;
			case 's':// 下移
				++row;
				break;
			case 'd':// 右移
				++col;
				break;
			case 'j':// 点开格子(模拟鼠标左键)
				player_open_cell(row, col);
				break;
			case 'k':// 尝试自动点开邻居(模拟鼠标中键)
				player_open_neighbor(row, col);
				break;
			case 'l':// 添加旗子标志(模拟鼠标右键)
				player_mark_flag(row, col);
				break;
			case 'i':// 添加问号标志
				player_mark_question(row, col);
				break;
			}
		}
		// 将光标限制在地图之内
		if (col < 0)col = 0;
		else if (col >= current_cols) col = current_cols - 1;
		if (row < 0)row = 0;
		else if (row >= current_rows) row = current_rows - 1;
		// 移动光标至对应位置
		set_cursor_rc(row, col);

		// 如果没有踩到地雷,判断是否胜利
		if (!is_game_over)
		{
			bool win_flag = true;
			// 遍历每一个格子
			for (int i = 0; i < current_rows; ++i)
			{
				for (int j = 0; j < current_cols; ++j)
				{
					// 如果有任何一个格子没有标志或者有问号标志
					// 或者有错误的旗子标志(即在没有地雷的格子标记了旗子)
					if (map[i][j].flag == cf_none || map[i][j].flag == cf_qmark
						|| (map[i][j].flag == cf_flag && !map[i][j].has_mine))
					{
						// 那么还未胜利
						win_flag = false;
						break;
					}
				}
				// 跳出循环
				if (!win_flag)break;
			}
			// 如果胜利了,跳出整个while循环
			if (win_flag)break;
		}
	}
	// 恢复光标位置到最后
	set_cursor_xy(x, y);
	printf("\n");
	// 打印结束语
	if (is_game_over)printf("你踩到雷了!\n");
	else printf("你赢了!\n");
	// 判断是否继续游戏
	printf("继续游戏(y/n)?:");
	char ch;
	scanf("%c", &ch);
	// 接收回车键
	getchar();
	if (ch == 'y' || ch == 'Y')return;
	else exit(0);
}

4. 玩家操作

在介绍玩家操作前,需要先介绍一个关键宏,以及一个关键函数。

关键宏:遍历邻居格子

这个宏的用途是遍历一个格子的每一个邻居,包括它自身。定义这个宏是因为在后续的过程中会经常出现遍历邻居的操作,因此在这里定义一个宏可以简化后续的代码。
宏参数中,rc是当前格子的行列索引,nrnc是邻居格子的行列索引,_cell是邻居格子。
在遍历邻居的过程中也会进行范围检测,避免超出地图边界。

// 遍历每个邻居,包括自己
#define ITERATE_NEIGHBOR_BEGIN(r, c, nr, nc, _cell) \
{\
	int _d[3] = {-1, 0, 1};\
	for(int _i = 0; _i < 3; ++_i)\
	{\
		for(int _j = 0; _j < 3; ++_j)\
		{\
			int nr = r + _d[_i];\
			int nc = c + _d[_j];\
			if(nr >= 0 && nc >= 0 && nr < current_rows && nc < current_cols)\
			{\
				struct cell* _cell = &map[nr][nc];
#define ITERATE_NEIGHBOR_END \
			}\
		}\
	}\
}

关键函数:获取周围地雷数

在后续的过程中会多次用到一个格子周围的地雷数,因此提供这样一个函数来获取周围地雷数。
这个函数会利用上面定义的宏来遍历邻居,然后对地雷进行计数。

// 获取一个格子周围的地雷个数(包括自身)
int get_num_mines_around(int row, int col)
{
	int num = 0;
	// 遍历每一个邻居
	ITERATE_NEIGHBOR_BEGIN(row, col, nr, nc, cell)
	{
		// 如果有地雷,那地雷数加1
		if (cell->has_mine)num++;
	}
	ITERATE_NEIGHBOR_END;
	return num;
}

4.1. 点开格子

这个函数执行点开格子的操作,如果格子已经被点开了,那么就直接返回。
否则判断是否有地雷,如果有地雷,则判断是否是第一次点开,如果是的话,则调用handle_first_mine来找一个没有地雷的格子进行替换。
后面继续判断这个格子有没有地雷(因为可能找不到格子可以替换),如果有地雷,则游戏失败,将is_game_over置为true并返回。
如果没有地雷,则获取周围地雷数,打印对应数字字符或者空格。如果没有地雷,则递归点开邻居。

void player_open_cell(int row, int col)
{
	struct cell* cell = &map[row][col];
	// 已经打开的格子不用再打开
	if (cell->flag == cf_number)
		return;
	// 点开的格子有地雷
	if (cell->has_mine)
	{
		// 如果是一开始就踩雷
		if (is_first_try)
			// 找一个没有地雷的格子进行交换
			handle_first_mine(row, col);
		
		// 不是一开始就踩雷或者
		// 一开始就踩雷但没有找到一个可以替换的格子
		if(map[row][col].has_mine)
		{
			// 游戏结束
			print_at_rc('!', row, col);
			is_game_over = true;
			return;
			//exit(0);
		}
	}
	// 不再是一开始了
	is_first_try = false;
	// 设置格子的标志
	cell->flag = cf_number;
	// 获取周围的地雷数
	int nums = get_num_mines_around(row, col);
	// 如果周围没有地雷,打印空格
	// 打印0的话会太密集,因为没有地雷的区域一般会比较多
	// 原本的扫雷里面也是没有打印0的
	if (nums == 0)print_at_rc(' ', row, col);
	// 非0的话打印对应的数字符号
	else print_at_rc(nums + '0', row, col);
	// 同时,如果周围没有地雷
	if (nums == 0)
	{
		// 遍历每一个邻居(包括自己)
		ITERATE_NEIGHBOR_BEGIN(row, col, nr, nc, cell)
		{
			// 如果邻居还没有点开标志
			if (cell->flag != cf_number)
				// 那么就点开这个邻居
				player_open_cell(nr, nc);
		}
		ITERATE_NEIGHBOR_END;
	}
}

在第一次点开格子就踩到地雷时,会调用这个函数来找一个没有地雷而且没有标志的格子进行替换。

// 处理第一次就踩雷的情况
void handle_first_mine(int row, int col)
{
	// 遍历每一个格子
	for (int i = 0; i < current_rows; ++i)
	{
		for (int j = 0; j < current_cols; ++j)
		{
			struct cell* cell = &map[i][j];
			// 如果这个格子没有地雷,而且也没有标志
			if (!cell->has_mine && cell->flag == cf_none)
			{
				// 将这个格子和踩到的地雷格子进行交换
				struct cell tmp = map[row][col];
				map[row][col] = map[i][j];
				map[i][j] = tmp;
			}
		}
	}
	// 后续踩到地雷不会再进行这样的处理
	is_first_try = false;
}

4.2. 自动点开邻居

这个函数执行自动点开邻居的操作。在原版的扫雷中,如果我们对已经排掉所有雷的格子点击鼠标中键的话,格子的没有被点开的邻居会被自动点开。
首先我们需要先判断这个格子已经是点开了的,然后获取格子周围的地雷数,遍历每个邻居,对标记了旗子的邻居进行计数。如果周围地雷数和标记为旗子的数目相同,则表示地雷全部排除。然后遍历所有没有被点开的邻居,点开。

void player_open_neighbor(int row, int col)
{
	struct cell* cell = &map[row][col];
	// 只有自己已经被点开了
	// 才能尝试自动去点开邻居
	if (cell->flag == cf_number)
	{
		// 先获取周围的雷数
		int nm = get_num_mines_around(row, col);
		// 标记为cf_flag的格子数
		int nf = 0;
		// 遍历每个邻居,包括自己
		ITERATE_NEIGHBOR_BEGIN(row, col, nr, nc, cell)
		{
			// 遇到被标记为旗子的格子,nf增加1
			if (cell->flag == cf_flag)
				nf++;
		}
		ITERATE_NEIGHBOR_END;
		// 如果被标记为旗子的格子的数目
		// 与周围地雷的数目相同
		if (nf == nm)
		{
			// 那么遍历每个邻居
			ITERATE_NEIGHBOR_BEGIN(row, col, nr, nc, cell)
			{
				// 点开那些没有被点开的格子
				if (cell->flag == cf_none || cell->flag == cf_qmark)
					player_open_cell(nr, nc);
			}
			ITERATE_NEIGHBOR_END;
		}
	}
}

4.3. 标记格子

这两个函数用来对格子进行标记,包括标记旗子和问号。当重复操作时,会消去标志。

// 给格子增加旗子标志
void player_mark_flag(int row, int col)
{
	struct cell* cell = &map[row][col];
	// 已经点开的格子不能进行标记
	if (cell->flag != cf_number)
	{
		// 如果已经是旗子标志了
		if (cell->flag == cf_flag)
		{
			// 去掉旗子标志
			cell->flag = cf_none;
			print_at_rc('*', row, col);
		}
		// 否则的话
		else
		{
			// 加上或者改成旗子标志
			cell->flag = cf_flag;
			print_at_rc('@', row, col);
		}
	}
}

// 给格子增加问号标志
void player_mark_question(int row, int col)
{
	struct cell* cell = &map[row][col];
	// 已经点开的格子不能进行标记
	if (cell->flag != cf_number)
	{
		// 如果已经是问号标志了
		if (cell->flag == cf_qmark)
		{
			// 去掉问号标志
			cell->flag = cf_none;
			print_at_rc('*', row, col);
		}
		// 否则的话
		else
		{
			// 加上或者改成问号标志
			cell->flag = cf_qmark;
			print_at_rc('?', row, col);
		}
	}
}

五 完整代码

#define _CRT_SECURE_NO_WARNINGS // 不使用_s系列函数
#include// 用于控制光标
#include// 用于控制光标
#include
#include
#include
#include
#include
// 格子上的标志
enum cell_flag
{
	// 什么标志都没有
	cf_none,
	// 数字标志,显示周围雷的个数,格子被点开时自动被标记
	cf_number,
	// 旗子标志,标记你认为有地雷的格子
	cf_flag,
	// 问号标志,标记你不确定是否有雷的格子
	cf_qmark
};
// 地图上的每个格子
struct cell
{
	// 这个格子有没有地雷
	bool has_mine;
	// 这个格子的标志
	enum cell_flag flag;
};
// 地图最大行数
#define MAX_ROWS 20
// 地图最大列数
#define MAX_COLS 40
// 地图最小行数
#define MIN_ROWS 2
// 地图最小列数
#define MIN_COLS 2
// 默认地图行数
#define DEFAULT_ROWS 16
// 默认地图列数
#define DEFAULT_COLS 30
// 默认地图中地雷数
#define DEFAULT_MINES 99
// 当前地图行数
int current_rows = DEFAULT_ROWS;
// 当前地图列数
int current_cols = DEFAULT_COLS;
// 地雷个数
int num_mines = DEFAULT_MINES;
// 地图
struct cell map[MAX_ROWS][MAX_COLS];
// 是不是第一次打开格子
// 用来防止第一下就踩雷
bool is_first_try = true;
// 游戏是否结束
bool is_game_over = false;
// 获取一个格子周围的地雷数
int get_num_mines_around(int row, int col);
// 1. 输入地图信息:大小 地雷个数
void input_map();
// 2. 初始化地图:清空地雷和所有标志
void init_map();
// 3. 生成地雷
void generate_mines();
// 4. 绘制地图
void draw_map();
// 5. 输出帮助信息
void print_help();
// 6. 获取/设置光标位置,在指定位置输出符号
void get_cursor_xy(int* x, int* y);
void set_cursor_rc(int row, int col);
void print_at_rc(char ch, int row, int col);
// 7. 处理玩家的键盘输入
void handle_player_input();
// 7.1 处理第一次点开格子是地雷的情况 
void handle_first_mine(int row, int col);
// 7.2 按下J键后点开格子
void player_open_cell(int row, int col);
// 7.3 按下K键后自动点开周围格子
void player_open_neighbor(int row, int col);
// 7.4 按下L键后插旗子
void player_mark_flag(int row, int col);
// 7.5 按下I键后打问号
void player_mark_question(int row, int col);
// 入口函数
int main()
{
	// 游戏主循环
	while (true)
	{
		// 清空控制台
		system("cls");
		// 输入地图信息
		input_map();
		// 初始化地图
		init_map();
		// 生成地雷
		generate_mines();
		// 绘制地图
		draw_map();
		// 输出帮助信息
		print_help();
		// 处理玩家的键盘输入
		handle_player_input();
	}
}

// 遍历每个邻居,包括自己
#define ITERATE_NEIGHBOR_BEGIN(r, c, nr, nc, _cell) \
{\
	int _d[3] = {-1, 0, 1};\
	for(int _i = 0; _i < 3; ++_i)\
	{\
		for(int _j = 0; _j < 3; ++_j)\
		{\
			int nr = r + _d[_i];\
			int nc = c + _d[_j];\
			if(nr >= 0 && nc >= 0 && nr < current_rows && nc < current_cols)\
			{\
				struct cell* _cell = &map[nr][nc];
#define ITERATE_NEIGHBOR_END \
			}\
		}\
	}\
}

// 获取一个格子周围的地雷个数(包括自身)
int get_num_mines_around(int row, int col)
{
	int num = 0;
	// 遍历每一个邻居
	ITERATE_NEIGHBOR_BEGIN(row, col, nr, nc, cell)
	{
		// 如果有地雷,那地雷数加1
		if (cell->has_mine)num++;
	}
	ITERATE_NEIGHBOR_END;
	return num;
}

// 输入地图信息
void input_map()
{
	// 用来接收输入错误后剩余的字符串,或者正常输入后的回车字符
	char tmp_buf[256] = { 0 };
	printf("默认地图:16行30列,99颗地雷\n");
	printf("使用默认地图(y/n)?:");
	char ch = 0;
	// 判断是否使用默认地图
	if (scanf("%c", &ch) == 1 && (ch == 'y' || ch == 'Y'))
	{
		// 设置默认行数 列数和地雷数
		current_rows = DEFAULT_ROWS;
		current_cols = DEFAULT_COLS;
		num_mines = DEFAULT_MINES;
		// 接收多余字符(回车)
		fgets(tmp_buf, sizeof(tmp_buf), stdin);
		return;
	}
	// 接收多余字符(回车)
	fgets(tmp_buf, sizeof(tmp_buf), stdin);
	// 输入地图大小
	printf("请输入地图大小:");
	while (scanf("%d%d", &current_rows, &current_cols) != 2)
	{
		// 输入错误,重新输入
		fprintf(stderr, "输入地图大小格式错误!\n");
		// 接收多余字符
		fgets(tmp_buf, sizeof(tmp_buf), stdin);
		printf("请重新输入:");
	}
	fgets(tmp_buf, sizeof(tmp_buf), stdin);
	// 检查并更正地图大小
	if (current_rows < MIN_ROWS)current_rows = MIN_ROWS;
	else if (current_rows > MAX_ROWS) current_rows = MAX_ROWS;
	if (current_cols < MIN_COLS)current_cols = MIN_COLS;
	else if (current_cols > MAX_COLS)current_cols = MAX_COLS;
	// 输入地雷个数
	printf("请输入地雷个数:");
	while (scanf("%d", &num_mines) != 1)
	{
		// 输入错误,重新输入
		fprintf(stderr, "输入地雷个数格式错误!\n");
		// 接收多余字符
		fgets(tmp_buf, sizeof(tmp_buf), stdin);
		printf("请重新输入:");
	}
	// 检查并更正地雷个数
	int max_mines = current_rows * current_cols - 1;
	if (num_mines <= 0)num_mines = 1;
	else if (num_mines >= max_mines) num_mines = max_mines;
	// 接收多余字符(回车)
	fgets(tmp_buf, sizeof(tmp_buf), stdin);
}

// 初始化地图
void init_map()
{
	// 下一次点开格子是第一次点开格子
	is_first_try = true;
	// 游戏结束标志
	is_game_over = false;
	// 遍历每一行
	for (int i = 0; i < current_rows; ++i)
	{
		// 遍历每一列
		for (int j = 0; j < current_cols; ++j)
		{
			// 清空地雷
			map[i][j].has_mine = false;
			// 清空标志
			map[i][j].flag = cf_none;
		}
	}
}

// 生成地雷
void generate_mines()
{
	// 设置随机数种子
	srand(time(0));
	// 每次生成一个地雷
	// 直到待生成的地雷数为0
	int mines_to_generate = num_mines;
	while (mines_to_generate > 0)
	{
		// 随机取地图的一行
		int row = rand() % current_rows;
		// 随机取地图的一列
		int col = rand() % current_cols;
		// 获取该行该列的格子
		struct cell* cell = &map[row][col];
		// 如果这个格子已经有地雷了
		if (cell->has_mine)
		{
			// 那么就跳过这个格子,重新找
			continue;
		}
		// 否则的话
		else
		{
			// 生成地雷
			cell->has_mine = true;
			// 并将待生成的地雷数减一
			mines_to_generate--;
		}
	}
}

// 绘制地图
void draw_map()
{
	// 清空控制台
	system("cls");
	// 遍历每一行
	for (int i = 0; i < current_rows; ++i)
	{
		// 遍历每一列
		for (int j = 0; j < current_cols; ++j)
		{
			// *表示一个没有任何标志的格子
			printf("*");
			// *后面跟一个空格保持地图美观
			// 保证行数和列数相等时是个正方形
			if (j != current_cols - 1)printf(" ");
		}
		// 打印右边界并且换行
		printf("|\n");
	}
	// 打印底边界
	for (int i = 0; i < current_cols; ++i)
		printf("--");
	// 删掉最后一个'-'并增加边界然后换行
	printf("\b|\n");
}

// 打印帮助信息
void print_help()
{
	// 打印操作方式和地图信息
	printf(
		"\n[操作方式]\n"
		"WASD键:移动光标\t"
		"J键:点开格子\n"
		"K键:自动排雷\t\t"
		"L键:插旗子\n"
		"I键:打问号\t\t"
		"ESC键:重新开始\n"
		"\n[地图信息]\n"
		"地图大小:%d行%d列\t"
		"地雷总数:%d\n",
		current_rows,
		current_cols,
		num_mines
	);
}

// 获取光标所在位置的x y值
void get_cursor_xy(int* x, int* y)
{
	HANDLE handle = GetStdHandle(STD_OUTPUT_HANDLE);
	CONSOLE_SCREEN_BUFFER_INFO csbi;
	GetConsoleScreenBufferInfo(handle, &csbi);
	*x = csbi.dwCursorPosition.X;
	*y = csbi.dwCursorPosition.Y;
}

// 将光标移至位置控制台(x, y)处
void set_cursor_xy(int x, int y)
{
	HANDLE handle = GetStdHandle(STD_OUTPUT_HANDLE);
	COORD coord = { x, y };
	SetConsoleCursorPosition(handle, coord);
}

// 将光标移至地图的第row行的第col列
void set_cursor_rc(int row, int col)
{
	HANDLE handle = GetStdHandle(STD_OUTPUT_HANDLE);
	COORD coord = {col * 2, row};
	SetConsoleCursorPosition(handle, coord);
}

// 在地图的第row行的第col列打印字符ch
void print_at_rc(char ch, int row, int col)
{
	set_cursor_rc(row, col);
	printf("%c", ch);
	set_cursor_rc(row, col);
}

// 接受玩家的键盘输入,并进行处理
void handle_player_input()
{
	// 先获取光标当前的x y值
	// 便于游戏结束时在最后位置输出信息
	int x, y;
	get_cursor_xy(&x, &y);
	// 将光标移至控制台左上角
	// 即地图的第一个格子
	set_cursor_rc(0, 0);
	int row = 0, col = 0;
	// 循环直到踩到地雷或者胜利
	while (!is_game_over)
	{
		int ch = _getch();
		// 0x1b表示ESC键
		// 重新开始
		if (ch == 0x1b)
		{
			return;
		}
		else
		{
			switch (ch)
			{
			case 'w':// 上移
				--row;
				break;
			case 'a':// 左移
				--col;
				break;
			case 's':// 下移
				++row;
				break;
			case 'd':// 右移
				++col;
				break;
			case 'j':// 点开格子(模拟鼠标左键)
				player_open_cell(row, col);
				break;
			case 'k':// 尝试自动点开邻居(模拟鼠标中键)
				player_open_neighbor(row, col);
				break;
			case 'l':// 添加旗子标志(模拟鼠标右键)
				player_mark_flag(row, col);
				break;
			case 'i':// 添加问号标志
				player_mark_question(row, col);
				break;
			}
		}
		// 将光标限制在地图之内
		if (col < 0)col = 0;
		else if (col >= current_cols) col = current_cols - 1;
		if (row < 0)row = 0;
		else if (row >= current_rows) row = current_rows - 1;
		// 移动光标至对应位置
		set_cursor_rc(row, col);

		// 如果没有踩到地雷,判断是否胜利
		if (!is_game_over)
		{
			bool win_flag = true;
			// 遍历每一个格子
			for (int i = 0; i < current_rows; ++i)
			{
				for (int j = 0; j < current_cols; ++j)
				{
					// 如果有任何一个格子没有标志或者有问号标志
					// 或者有错误的旗子标志(即在没有地雷的格子标记了旗子)
					if (map[i][j].flag == cf_none || map[i][j].flag == cf_qmark
						|| (map[i][j].flag == cf_flag && !map[i][j].has_mine))
					{
						// 那么还未胜利
						win_flag = false;
						break;
					}
				}
				// 跳出循环
				if (!win_flag)break;
			}
			// 如果胜利了,跳出整个while循环
			if (win_flag)break;
		}
	}
	// 恢复光标位置到最后
	set_cursor_xy(x, y);
	printf("\n");
	// 打印结束语
	if (is_game_over)printf("你踩到雷了!\n");
	else printf("你赢了!\n");
	// 判断是否继续游戏
	printf("继续游戏(y/n)?:");
	char ch;
	scanf("%c", &ch);
	// 接收回车键
	getchar();
	if (ch == 'y' || ch == 'Y')return;
	else exit(0);
}

// 处理第一次就踩雷的情况
void handle_first_mine(int row, int col)
{
	// 遍历每一个格子
	for (int i = 0; i < current_rows; ++i)
	{
		for (int j = 0; j < current_cols; ++j)
		{
			struct cell* cell = &map[i][j];
			// 如果这个格子没有地雷,而且也没有标志
			if (!cell->has_mine && cell->flag == cf_none)
			{
				// 将这个格子和踩到的地雷格子进行交换
				struct cell tmp = map[row][col];
				map[row][col] = map[i][j];
				map[i][j] = tmp;
			}
		}
	}
	// 后续踩到地雷不会再进行这样的处理
	is_first_try = false;
}

void player_open_cell(int row, int col)
{
	struct cell* cell = &map[row][col];
	// 已经打开的格子不用再打开
	if (cell->flag == cf_number)
		return;
	// 点开的格子有地雷
	if (cell->has_mine)
	{
		// 如果是一开始就踩雷
		if (is_first_try)
			// 找一个没有地雷的格子进行交换
			handle_first_mine(row, col);
		
		// 不是一开始就踩雷或者
		// 一开始就踩雷但没有找到一个可以替换的格子
		if(map[row][col].has_mine)
		{
			// 游戏结束
			print_at_rc('!', row, col);
			is_game_over = true;
			return;
			//exit(0);
		}
	}
	// 不再是一开始了
	is_first_try = false;
	// 设置格子的标志
	cell->flag = cf_number;
	// 获取周围的地雷数
	int nums = get_num_mines_around(row, col);
	// 如果周围没有地雷,打印空格
	// 打印0的话会太密集,因为没有地雷的区域一般会比较多
	// 原本的扫雷里面也是没有打印0的
	if (nums == 0)print_at_rc(' ', row, col);
	// 非0的话打印对应的数字符号
	else print_at_rc(nums + '0', row, col);
	// 同时,如果周围没有地雷
	if (nums == 0)
	{
		// 遍历每一个邻居(包括自己)
		ITERATE_NEIGHBOR_BEGIN(row, col, nr, nc, cell)
		{
			// 如果邻居还没有点开标志
			if (cell->flag != cf_number)
				// 那么就点开这个邻居
				player_open_cell(nr, nc);
		}
		ITERATE_NEIGHBOR_END;
	}
}

// 根据标记情况,自动点开周围格子
void player_open_neighbor(int row, int col)
{
	struct cell* cell = &map[row][col];
	// 只有自己已经被点开了
	// 才能尝试自动去点开邻居
	if (cell->flag == cf_number)
	{
		// 先获取周围的雷数
		int nm = get_num_mines_around(row, col);
		// 标记为cf_flag的格子数
		int nf = 0;
		// 遍历每个邻居,包括自己
		ITERATE_NEIGHBOR_BEGIN(row, col, nr, nc, cell)
		{
			// 遇到被标记为旗子的格子,nf增加1
			if (cell->flag == cf_flag)
				nf++;
		}
		ITERATE_NEIGHBOR_END;
		// 如果被标记为旗子的格子的数目
		// 与周围地雷的数目相同
		if (nf == nm)
		{
			// 那么遍历每个邻居
			ITERATE_NEIGHBOR_BEGIN(row, col, nr, nc, cell)
			{
				// 点开那些没有被点开的格子
				if (cell->flag == cf_none || cell->flag == cf_qmark)
					player_open_cell(nr, nc);
			}
			ITERATE_NEIGHBOR_END;
		}
	}

}

// 给格子增加旗子标志
void player_mark_flag(int row, int col)
{
	struct cell* cell = &map[row][col];
	// 已经点开的格子不能进行标记
	if (cell->flag != cf_number)
	{
		// 如果已经是旗子标志了
		if (cell->flag == cf_flag)
		{
			// 去掉旗子标志
			cell->flag = cf_none;
			print_at_rc('*', row, col);
		}
		// 否则的话
		else
		{
			// 加上或者改成旗子标志
			cell->flag = cf_flag;
			print_at_rc('@', row, col);
		}
	}
}

// 给格子增加问号标志
void player_mark_question(int row, int col)
{
	struct cell* cell = &map[row][col];
	// 已经点开的格子不能进行标记
	if (cell->flag != cf_number)
	{
		// 如果已经是问号标志了
		if (cell->flag == cf_qmark)
		{
			// 去掉问号标志
			cell->flag = cf_none;
			print_at_rc('*', row, col);
		}
		// 否则的话
		else
		{
			// 加上或者改成问号标志
			cell->flag = cf_qmark;
			print_at_rc('?', row, col);
		}
	}
}

你可能感兴趣的:(c语言,c++)