c/c++游戏编程之扫雷

(tips: 因为很多同学都只学过c语言或刚刚入门c++,所以本系列文章主要以由浅入深的方式进行讲解,后续才会使用语言的新特性、标准模板库、面向对象、图形化编程等。请莫要心急,迷雾会慢慢被揭开)

扫雷是Windows平台的家喻户晓的经典小游戏,本节内容将讲解如何在控制台完成扫雷的开发。

在这里插入图片描述

先看一下运行效果图:

在这里插入图片描述

我们先给出代码的雏形,每个全局变量后面都有注释,如果暂时无法理解它们的用处,没关系,待会你就明白了。

#include <iostream>
#include <Windows.h>
#include <time.h>
#include <conio.h>

using std::cout;
using std::endl;

const int MAP_WIDTH = 24; //地图宽度
const int MAP_HEIGHT = 24; //地图高度
const float PROP_OF_MINES = 0.1f; //地雷比例
const char CHAR_MINE = '*'; //地雷符号
const char CHAR_UNKNOW = '?'; //格子符号
const int GAME_OVER_FAILED = 0; //游戏失败标识
const int GAME_OVER_SUCCESSED = 1; //游戏成功标识
const int GAME_NOTH = 2; //游戏正常标识

int g_cursorPosx = 0; //光标横坐标
int g_cursorPosy = 0; //光标纵坐标
int g_isNotOpen = MAP_WIDTH * MAP_HEIGHT; //未打开的格子数
int g_numOfMines = MAP_WIDTH * MAP_HEIGHT * PROP_OF_MINES; //地雷数

//格子
struct Block {
	bool isOpen; //格子是否打开
	bool isMine; //此格是不是地雷
	unsigned int countOfMines; //此格周围的地雷数
} map[MAP_WIDTH + 2][MAP_HEIGHT + 2]; //二维数组比地图大一圈

int main() {
	srand(unsigned int(time(NULL))); //初始化随机数种子
	
	_getch();
	return 0;
}

看过前几篇文章的同学都应该知道,游戏程序的流程大致可以分成三个部分:

1.游戏初始化阶段:用于加载资源,生成地图数据等操作

2.游戏运行阶段:主要是由一个循环负责,在循环里做输入处理、状态更新、图像绘制等操作

3.游戏结束阶段:可以用于释放资源,输出结束信息等操作

先定义一个名为GotoPos的函数来移动光标在控制台中的位置(窗口坐标系我在前面的文章有过介绍,这里不再赘述),这个函数尤其重要:

void GotoPos(int x, int y) {
	HANDLE hout = GetStdHandle(STD_OUTPUT_HANDLE);
	COORD cor = { x, y };
	SetConsoleCursorPosition(hout, cor);
}

好了,话不多说,先来第一步:游戏初始化。我们用一个名为InitGame的函数进行初始化:

void InitGame() {
	memset(map, 0, sizeof(map)); //置零未初始化的结构体数组

	//生成地雷
	for (int index = 1; index < g_numOfMines; ) {
		//生成的下标范围在[1, 99],实际容器范围是[0, 100]
		int randomIndexX = rand() % (MAP_WIDTH - 1) + 1;
		int randomIndexY = rand() % (MAP_HEIGHT - 1) + 1;

		//如果此格不是地雷(为防止地雷重复生成到同一格)
		if (!map[randomIndexX][randomIndexY].isMine) {
			
			map[randomIndexX][randomIndexY].isMine = true; //标记为地雷

			//地雷四周的格子的地雷计数加1
			for (int indexX = randomIndexX - 1; indexX < randomIndexX + 2; ++indexX) {
				for (int indexY = randomIndexY - 1; indexY < randomIndexY + 2; ++indexY) {
					map[indexX][indexY].countOfMines++;
				}
			}

			++index; //生成一颗雷,index才加一
		}
	}

	//打印地图
	for (int indexY = 1; indexY <= MAP_HEIGHT; ++indexY) {
		for (int indexX = 1; indexX <= MAP_WIDTH; ++indexX) {
			cout << CHAR_UNKNOW; //输出字符 '?'
			map[indexX][indexY].isOpen = false; //把格子的状态置为未打开
		}
		cout << endl;
	}

	GotoPos(g_cursorPosx, g_cursorPosy); 
}

我们用随机数生成地雷,且生成的地雷的坐标在 [1, MAP_WIDTH] 这个闭区间内。我们为地雷附近的格子的地雷计数加一:

在这里插入图片描述

但是,当地雷生成到地图边界时,为防止越界访问,我们就不得不判断其周围还有没有格子:

在这里插入图片描述

为了避免作出多余的判断,我们将二维数组扩大"一圈",这也是代码中为什么定义map时数组尺寸是MAP_WIDTH + 2MAP_HEIGHT + 2 的原因。

扩大前

在这里插入图片描述

扩大后

在这里插入图片描述

因此我们在生成地雷对其周围格子地雷计数加一时,就不用担心什么越界,只要将下标访问控制在绿色的范围内。

在这里插入图片描述

//打印地图
	for (int indexY = 1; indexY <= MAP_HEIGHT; ++indexY) {
		for (int indexX = 1; indexX <= MAP_WIDTH; ++indexX) {
			cout << CHAR_UNKNOW; //输出字符 '?'
			map[indexX][indexY].isOpen = false; //把格子的状态置为未打开
		}
		cout << endl;
	}

	GotoPos(g_cursorPosx, g_cursorPosy); 

我们用字符?’代表格子,并遍历所有格子把它们的状态置为关闭。这便是游戏初始化的所有内容。

我们再用非阻塞的方式获取并处理键盘输入(switch里的数字是字母W、S、A、D大小写的ASCII值,
32是空格符的ASCII值
),也就是说我们用WSAD键控制光标移动, 用空格键打开格子。

//处理键盘输入
const int InputProcess() {
	char cinput;

	if (_kbhit()) {

		cinput = _getch(); //使用_getch()需要 #include <conio.h>
		unsigned int xtrans = 0;
		unsigned int ytrans = 0;

		switch (cinput) {
		case 87: case 119: {
			if (g_cursorPosy > 0) {
				ytrans = -1;
			}
			break;
		}
		case 53: case 115: {
			if (g_cursorPosy < MAP_HEIGHT - 1) {
				ytrans = 1;
			}
			break;
		}
		case 65: case 97: {
			if (g_cursorPosx > 0) {
				xtrans = -1;
			}
			break;
		}
		case 68: case 100: {
			if (g_cursorPosx < MAP_WIDTH - 1) {
				xtrans = 1;
			}
			break;
		}
		case 32: {
			//如果打开的位置是雷,返回信号GAME_OVER
			if (map[g_cursorPosx + 1][g_cursorPosy + 1].isMine) {
				return GAME_OVER_FAILED;
			}

			DFS(g_cursorPosx + 1, g_cursorPosy + 1);
			break;
		}
		default: {
			break;
		}
		}

		g_cursorPosx += xtrans;
		g_cursorPosy += ytrans;

		GotoPos(g_cursorPosx, g_cursorPosy);
	}

	return GAME_NOTH;
}

上述代码中的DFS函数定义:

//深度优先搜索
void DFS(int posx, int posy) {
	//已经点开过或者越界就返回
	if (map[posx][posy].isOpen || posx <= 0 || posx >= MAP_WIDTH || posy <= 0 || posy >= MAP_HEIGHT) {
		return;
	}
	
	//点开此格
	GotoPos(posx - 1, posy - 1);
	cout << (0 == map[posx][posy].countOfMines ? ' ' : char(map[posx][posy].countOfMines + 48));
	map[posx][posy].isOpen = true;
	g_isNotOpen--;

	//如果此格周围存在地雷,则返回
	if (map[posx][posy].countOfMines > 0) {
		return;
	}

	//以递归深度优先遍历地图格子
	DFS(posx + 1, posy);
	DFS(posx - 1, posy);
	DFS(posx, posy + 1);
	DFS(posx, posy - 1);
}

我们以递归的方式进行深度优先搜索,即从打开的位置向上下左右四个方向继续打开,如果遇到了周围有雷的格子,打开它,然后不再继续打开直接返回。在InputProcess函数的case 32中,我们可以看到,如果这个格子是雷,便直接返回游戏结束标识。

在这里插入图片描述

如果不是雷,就调用DFS函数。在DFS函数里,如果这个格子周围有地雷,就直接打开它:

在这里插入图片描述

如果这个格子周围没有地雷,就继续递归搜索,疯狂打开,直到遇到有地雷的格子:

在这里插入图片描述

然后定义一个CheckSweeping函数,用来检测玩家是否扫完了所有的雷,其原理很简单,就是判断剩下的未打开的格子数量是否与地雷数量相等,如果相等,就说明游戏扫雷成功。

const int CheakSweeping() {
	
	if (g_numOfMines != g_isNotOpen) {
		return GAME_NOTH;
	}

	return GAME_OVER_SUCCESSED;

}

最后定义一个游戏结束的函数GameOver

void GameOver(int type) {
	system("cls"); //清空控制台
	GotoPos(0, 0); //光标回到左上角
	//将所有格子的打开状态打印出来
	for (int indexY = 1; indexY <= MAP_HEIGHT; ++indexY) {
		for (int indexX = 1; indexX <= MAP_WIDTH; ++indexX) {
			if (map[indexX][indexY].isMine) {
				cout << CHAR_MINE;
			}
			else {
				cout << (0 == map[indexX][indexY].countOfMines ? ' ' : char(map[indexX][indexY].countOfMines + 48));
			}
		}
		cout << endl;
	}
	
	//判断是哪种游戏结束,踩到雷了还是扫雷成功
	if (0 == type) {
		cout << "-Game Over-\n-Don't lose heart, you'll do better!-";
	}
	else if (1 == type) {
		cout << "-Game Over-\n-Congratulations!-\n-You won!-";
	}

	GotoPos(g_cursorPosx, g_cursorPosy);
}

到这里,游戏核心代码全部完成啦~,以下是所有代码

#include <iostream>
#include <Windows.h>
#include <time.h>
#include <conio.h>

using std::cout;
using std::endl;

const int MAP_WIDTH = 24; //地图宽度
const int MAP_HEIGHT = 24; //地图高度
const float PROP_OF_MINES = 0.1f; //地雷比例
const char CHAR_MINE = '*'; //地雷符号
const char CHAR_UNKNOW= '?'; //未知符号
const int GAME_OVER_FAILED = 0; //游戏失败标识
const int GAME_OVER_SUCCESSED = 1; //游戏成功标识
const int GAME_NOTH = 2; //游戏正常标识

int g_cursorPosx = 0; //光标横坐标
int g_cursorPosy = 0; //光标纵坐标
int g_isNotOpen = MAP_WIDTH * MAP_HEIGHT; //未打开的格子数
int g_numOfMines = MAP_WIDTH * MAP_HEIGHT * PROP_OF_MINES; //地雷数

struct Block {
	bool isOpen; //格子是否打开
	bool isMine; //此格是不是地雷
	unsigned int countOfMines; //此格周围的地雷计数
} map[MAP_WIDTH + 2][MAP_HEIGHT + 2]; //二维数组比地图大一圈


void GotoPos(int x, int y) {
	HANDLE hout = GetStdHandle(STD_OUTPUT_HANDLE);
	COORD cor = { x, y };
	SetConsoleCursorPosition(hout, cor);
}

void InitGame() {
	memset(map, 0, sizeof(map)); //置零未初始化的结构体数组

	//生成地雷
	for (int index = 1; index < g_numOfMines; ) {
		//生成的下标范围在[1, 99],实际容器范围是[0, 100]
		int randomIndexX = rand() % (MAP_WIDTH - 1) + 1;
		int randomIndexY = rand() % (MAP_HEIGHT - 1) + 1;

		//如果此格不是地雷(为防止地雷重复生成到同一格)
		if (!map[randomIndexX][randomIndexY].isMine) {
			
			map[randomIndexX][randomIndexY].isMine = true; //标记为地雷

			//地雷四周的格子的地雷计数加1
			for (int indexX = randomIndexX - 1; indexX < randomIndexX + 2; ++indexX) {
				for (int indexY = randomIndexY - 1; indexY < randomIndexY + 2; ++indexY) {
					map[indexX][indexY].countOfMines++;
				}
			}

			++index; //生成一颗雷,index才加一
		}
	}

	//打印地图
	for (int indexY = 1; indexY <= MAP_HEIGHT; ++indexY) {
		for (int indexX = 1; indexX <= MAP_WIDTH; ++indexX) {
			cout << CHAR_UNKNOW; //输出字符 '?'
			map[indexX][indexY].isOpen = false; //把格子的状态置为未打开
		}
		cout << endl;
	}

	GotoPos(g_cursorPosx, g_cursorPosy); 
}

//深度优先搜索
void DFS(int posx, int posy) {
	//已经点开过或者越界就返回
	if (map[posx][posy].isOpen || posx <= 0 || posx >= MAP_WIDTH || posy <= 0 || posy >= MAP_HEIGHT) {
		return;
	}
	
	//点开此格
	GotoPos(posx - 1, posy - 1);
	cout << (0 == map[posx][posy].countOfMines ? ' ' : char(map[posx][posy].countOfMines + 48));
	map[posx][posy].isOpen = true;
	g_isNotOpen--;

	//如果此格周围存在地雷,则返回
	if (map[posx][posy].countOfMines > 0) {
		return;
	}

	//以递归深度优先遍历地图格子
	DFS(posx + 1, posy);
	DFS(posx - 1, posy);
	DFS(posx, posy + 1);
	DFS(posx, posy - 1);
}

//处理键盘输入
const int InputProcess() {
	char cinput;

	if (_kbhit()) {

		cinput = _getch(); //使用_getch()需要 #include <conio.h>
		unsigned int xtrans = 0;
		unsigned int ytrans = 0;

		switch (cinput) {
		case 87: case 119: {
			if (g_cursorPosy > 0) {
				ytrans = -1;
			}
			break;
		}
		case 53: case 115: {
			if (g_cursorPosy < MAP_HEIGHT - 1) {
				ytrans = 1;
			}
			break;
		}
		case 65: case 97: {
			if (g_cursorPosx > 0) {
				xtrans = -1;
			}
			break;
		}
		case 68: case 100: {
			if (g_cursorPosx < MAP_WIDTH - 1) {
				xtrans = 1;
			}
			break;
		}
		case 32: {
			//如果点开的位置是雷,返回信号GAME_OVER
			if (map[g_cursorPosx + 1][g_cursorPosy + 1].isMine) {
				return GAME_OVER_FAILED;
			}

			DFS(g_cursorPosx + 1, g_cursorPosy + 1);

			break;
		}
		default: {
			break;
		}
		}

		g_cursorPosx += xtrans;
		g_cursorPosy += ytrans;

		GotoPos(g_cursorPosx, g_cursorPosy);
	}

	return GAME_NOTH;
}

const int CheakSweeping() {
	
	if (g_numOfMines != g_isNotOpen) {
		return GAME_NOTH;
	}

	return GAME_OVER_SUCCESSED;

}

void GameOver(int type) {
	system("cls");
	GotoPos(0, 0);
	for (int indexY = 1; indexY <= MAP_HEIGHT; ++indexY) {
		for (int indexX = 1; indexX <= MAP_WIDTH; ++indexX) {
			if (map[indexX][indexY].isMine) {
				cout << CHAR_MINE;
			}
			else {
				cout << (0 == map[indexX][indexY].countOfMines ? ' ' : char(map[indexX][indexY].countOfMines + 48));
			}
		}
		cout << endl;
	}

	if (0 == type) {
		cout << "-Game Over-\n-Don't lose heart, you'll do better!-";
	}
	else if (1 == type) {
		cout << "-Game Over-\n-Congratulations!-\n-You won!-";
	}

	GotoPos(g_cursorPosx, g_cursorPosy);
}

int main() {
	srand(unsigned int(time(NULL))); //初始化随机数种子
	
	int gameOverType;

	InitGame();

	while (1) {
		if (GAME_OVER_FAILED == InputProcess()) {
			gameOverType = 0;
			break;
		};
		if (GAME_OVER_SUCCESSED == CheakSweeping()) {
			gameOverType = 1;
			break;
		}
		Sleep(16);
	}

	GameOver(gameOverType);
	_getch();
	return 0;
}

文章持续更新中!
下节将开启图形化编程之旅~~~

求点赞、收藏!欢迎到评论区留言,有问必答!
作者水平有限,如果有误,欢迎指正!
编译环境:Visual Studio 2019

  • 7
    点赞
  • 69
    收藏
    觉得还不错? 一键收藏
  • 6
    评论
评论 6
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值