一、为什么选择扫雷作为第一个项目

前段时间刚学完分支循环、数组和函数,总觉得自己"学得会,用不出"。于是决定动手做一个控制台版的扫雷——把学到的东西真正用起来。
这篇博客记录的就是这个过程:从控制台版本的实现,到后来尝试用 AI 辅助做一个界面版本。 不敢说是什么教程,更像是一份学习路上的阶段性总结。

二、控制台版本扫雷游戏的实现

2.1 文件创建

创建main.c、game.c、game.h文件。将项目的实现逻辑放入main.c文件,游戏的功能放入game.c文件,利用game.h头文件将两个源文件联系起来。

2.2 菜单操作

首先打印菜单,我们可以使用一个函数封装打印菜单的操作。

void menu() {
	printf("------------------\n");
	printf("------1.start-----\n");
	printf("------0.exit------\n");
	printf("------------------\n");
}

在main函数中调用menu,运行结果如下
菜单打印运行结果
接着使用scanf函数和switch函数,根据用户输入的数字开始游戏或退出游戏。

int choose = 0;
scanf("%d", &choose);
switch (choose) {
case 1:
	//game();
	printf("游戏中");
	break;
case 0:
	printf("退出游戏");
	break;
default:
	printf("输入无效,请重新输入");
}

game()函数还未定义,故把game函数注释,测试用户选择功能是否正常。运行结果如下:
输入1
输入0
其他输入
用户可以正常选择。但我们发现:无效输入时程序会直接退出,而不是让用户重新输入;用户结束游戏时,程序也会直接退出,不能选择是否继续玩。
当程序开始运行时,至少打印一次菜单,所以使用do-while循环解决这个问题会是一个可行的办法。

int choose = 0;
do
{
	menu();//打印菜单
	//用户选择
	scanf("%d", &choose);
	switch (choose) {
	case 1:
		//game();
		printf("游戏中\n");
		printf("不过瘾可以再来一把哟\n");
		break;
	case 0:
		printf("退出游戏\n");
		break;
	default:
		printf("输入无效,请重新输入\n");
	}
	printf("\n");
} while (choose);

从上面的代码,我们不难发现。输入的值不仅可以让程序进入相对应的分支,还能控制循环条件。若选择0,退出游戏的同时,循环也会退出;若选择1,结束游戏时重新进入循环选择;若无效输入,choose为非零整数,依然为真,即可以重新进入循环。
运行结果如下:
测试循环
至此,菜单操作功能完善。

2.3 游戏功能实现

接下来我们在game.c中实现游戏功能,并在game.h中声明。

2.3.1 棋盘创建

在进行扫雷游戏时,玩家不能看到雷的位置,但游戏的进行需要雷的位置。因此,我们需要创建两个棋盘。一个棋盘让玩家只能看见附近3*3的雷的个数,另一个棋盘知道所有雷的位置。
使用二维数组创建棋盘。棋盘棋盘的行列数默认为9,且被定义为ROW和COL。并定义ROWS和COLS。

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

为什么要定义ROWS和COLS?
在创建数组时,不能简单地创建如show[ROW][COL] = … 假设数组长度这么设置,会给游戏留下隐患。举个例子,若玩家选择边缘处的坐标,程序在判断周围是否有雷时会越界访问数组。正因如此,可以这么创建数组:

char show[ROWS][COLS] = { 0 };
char play[ROWS][COLS] = { 0 };

这么创建数组,给数组留了缓冲区,解决了越界访问数组的问题。

2.3.2 初始化棋盘

当创建好了棋盘之后,就可以对棋盘进行初始化了。在show棋盘中,可以用*表示未被翻开的格子;在play棋盘中,用字符‘1’表示有雷,‘0’表示没雷。
初始化如下:

void init_board(char arr[ROWS][COLS],char s) {

	//遍历棋盘并写入指定字符
	for (int i = 0;i <ROWS ;i++) {
		for (int j = 0;j < COLS;j++) {
			arr[i][j] = s;
		}
		
	}
}

对所有数组元素初始化,这样,当边缘处格子被选择时,程序扫描缓冲区时与扫描’0’无异。

2.3.3 打印show棋盘

打印show棋盘主体只需要两个for循环,但棋盘展示不能只打印主体部分。为了让游戏更有可玩性,可以参考如下打印方式:

void print_board(char arr[ROWS][COLS], int r, int c) {
	//打印顶部数字且使用|分隔每个坐标,方便查找坐标
	for (int i = 0;i <= c;i++) {
		printf("%d| ",i);
	}
	printf("\n");
	//打印棋盘主体部分
	for (int i = 1;i <= r;i++) {
		//打印每行前先打印一行-|-,起到分隔作用,与顶部对齐
		for (int k = 0;k <= c;k++) {
			printf("-|-");
		}
		printf("\n");
		printf("%d| ", i);//在每行前加上数字,方便查找坐标
		for (int j = 1;j <= c;j++) {
			printf("%c| ", arr[i][j]);
		}
		printf("\n");
	}
}

将该打印方式封装成一个函数print_board。
运行结果如下:
打印棋盘
已经成功打印棋盘,我们就可以进入下一步了。

2.3.4 布置雷

要想布置雷,首先确定雷的个数。定义雷数默认10。

#define MINES 10

雷的个数确定之后,我们不难想到,利用循环和随机数生成10个随机坐标并布置在play棋盘中。

void set_mine(char arr[ROWS][COLS]) {
	int count = MINES;
	while (count--) {
		//生成两个1-9的随机数表示行列
		int a = 1 + rand() % 9;//a表示所在行
		int b = 1 + rand() % 9;//b表示所在列
		//printf("%d ", a);
		arr[a][b] = '1';//对随机坐标写入'1'布置雷
	}
}

在main函数利用当前时间戳设置随机数种子

srand((unsigned int)time(NULL));

此时布置雷已经写好。要想知道是否布置成功可以打印play棋盘。运行结果如下:
play棋盘

不难发现随机雷已经布置成功。当然,也可以先布置雷后打印棋盘,且更有条理。

2.3.5 玩家扫雷过程框架

当菜单,初始化,布置雷功能可以正常运行,就可以开始处理游戏过程。游戏过程中,玩家不断输入坐标进行多次排雷操作,每次排雷成功立即执行下一次排雷,直到被炸死或排雷完成。

int count = 0;
printf("输入格式:行 列\n");
printf("示例:3 4\n");
while (1) {
	//打印show棋盘
	print_board(show, ROW, COL);
	int r = 0;
	int c = 0;
	scanf("%d %d", &r, &c);//键盘输入坐标
	//判断输入坐标是否合法
	if (show[r][c] == '*' && r <= ROW && r > 0 && c <= COL && c > 0) {
		//判断输入坐标是否为雷
		if (play[r][c] == '0') {
				printf("排雷成功\n");
			}
		else {
			printf("你已被炸死\n");
			break;
		}

	}
	else {
		printf("非法输入,排雷失败\n");
	}
}

把打印棋盘的功能放入了循环,玩家每次排雷成功都能刷新棋盘。
外层if判断输入坐标是否为非法输入。外层if判断输入坐标是否有雷。
创建了一个count计数器,方便之后通过计算排雷数判定游戏胜利。
除游戏胜利以外,进行功能测试:
排雷成功
非法输入
踩雷

2.3.6 扫描

玩家输入坐标,程序立即计算周围雷数,并将“*”替换为周围雷个数。在替换之前,需要先进行搜索,计算。
可以将这个过程封装成一个返回值为char的函数,若将play数组以及玩家输入的坐标传入该函数,即可计算周围的’1’的个数并返回。
参考写法:

char search_mine(char arr[ROWS][COLS], int a, int b) {
	//a,b表示传入行列
	
	//sum变量存储附近总雷数
	char sum = 0;
	//计算附近3*3格子的雷数
	for (int i = -1;i <= 1;i++) {
		for (int j = -1;j <= 1;j++) {
			sum += arr[i + a][j + b] - '0';
		}
	}
	return sum;
}

2.3.7 替换

计算完整块区域的雷总数,就可以将原本的"*"替换为雷的总数。不妨把这个过程封装成一个void函数。

void sweep_mine(char show[ROWS][COLS],char play[ROWS][COLS], int a, int b) {
	char sum = search_mine(play, a, b);
	show[a][b] = '0' + sum;
}

将show和play数组分别传入该函数的参数1,参数2
用sum接收返回的雷数,并对show数组做修改。
通过运行测试修改是否成功:
在这里插入图片描述

2.3.8 胜利判定

前面已经创建了count计数器,但还没有使用。要想判断玩家是否把雷全部排完,此时就要用到计数器统计排雷次数,即每次排雷成功count+1,当count达到上限,游戏结束。
在game函数判断是否有雷的if分支中,可以新增一个if-else分支。
参考代码如下:

//判断输入坐标是否为雷
if (play[r][c] == '0') {
		sweep_mine(show,play,r,c);
		count++;
		printf("排雷成功\n");
		//判断是否排完雷
		if (count == ROW * COL - MINES) {
			printf("你赢了,游戏结束\n");
			break;
		}

将地雷数MINES改成80,测试一下胜利判定是否生效:
游戏胜利
至此,扫雷游戏的基础功能已全部实现。

2.4 优化

2.4.1 炸开一片

实现了扫雷游戏的基础功能之后,就可以运行使用。然而,在使用的过程中,我们发现:每次游戏进行得太久。
由于每次排雷只能选择选择一个格子,且当某格子显示0时,其周围的3*3区域完全没有必要手动翻开。
所以,修改sweep_mine函数,当排雷位置周围无雷时,自动向外扩张。

void sweep_mine(char show[ROWS][COLS],char play[ROWS][COLS], int a, int b) {
	if (a < 1 || a>9 || b < 1 || b>9 || show[a][b] != '*') return;
	char sum = search_mine(play, a, b);
	show[a][b] = '0' + sum;
	count++;
	if (show[a][b] == '0') {
		for (int i = -1;i <= 1;i++) {
			for (int j = -1;j <= 1;j++) {
				sweep_mine(show, play, a + i, b + j);
			}
		}
	}
}

在此基础上,把count设为全局变量,并删除在game函数中的count++。
修改后,每次选择到周围雷数为0的格子,对整块3*3区域重新调用sweep_mine函数。每次进入函数a,b被判断是否合法,是否已经被排。在show棋盘,每次"*"被修改为数字,count+1。
不妨将MINES改为5,运行测试炸开一片功能:
炸开一片
程序正常运行。

2.4.2 细节优化

每次排雷开始前,屏幕会打印棋盘,也就意味着每玩一次游戏会打印多个棋盘,看着不够美观。所以可以每次扫完雷进行一次清屏操作
但随之而来的又是一个小问题:清屏操作会把程序刚刚打印的“排雷成功”清除
所以,我们可以在每次排雷结束时等待半秒进入清屏操作
只需在game函数的while循环末尾添加如下代码:

Sleep(500);
system("cls");

以上操作完成,控制台版扫雷游戏彻底完成。

三、界面版本扫雷游戏实现

完成了控制台版本之后,就可以尝试利用AI做出一个带界面扫雷游戏。以下操作使用deepseek最近发布的deepseek harness。
选择deepseek V4 flash模型。
发送ai提示词:

在尽量少改动代码的基础上,使⽤raylib库,将扫雷游戏改造成带界⾯的版本,raylib我已经安装好了,路径是:C:\raylib\raylib-6.0_win64_msvc16

ai实现:
ai辅助
运行:
运行
运行

四、结语

控制台版靠硬写,界面版靠 AI 搭骨架、自己填血肉。同样的功能,开发周期从"周"压缩到"天"——这不是偷懒,是把时间花在了真正该思考的地方。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐