从零写一个扫雷:两个数组、一个坑,和三个后来加的功能

从零写一个扫雷:两个数组、一个坑,和三个后来加的功能

开头先说清楚

扫雷这个游戏,玩法人人都懂:格子里藏着雷,点开安全格,把雷全标出来就赢。

但自己写一遍,难点不在"点开",在怎么"知道" 。你得让程序知道:这个格子有没有雷、周围有几颗雷、还有多少格没排、什么时候算赢。

这篇文章讲我是怎么一点点把它做出来的。代码是 C 语言,一共三个文件,加起来不到三百行。会写 for 循环和数组就能看懂。


一、先想清楚要做什么

动手之前,我把要做的功能列了一遍:

markdown 复制代码
1. 9×9 的棋盘,随机埋 10 颗雷
2. 把棋盘打印出来(一开始全是 *)
3. 玩家输入坐标,程序判断那一格有没有雷
4. 没雷:显示它周围 8 格里有几颗雷
5. 有雷:游戏结束
6. 把 71 个安全格全排开:赢

第 6 条是最早要想明白的一条 ------ 赢的条件是"排开 71 格",不是"标出 10 颗雷" 。

71 从哪来?9 × 9 - 10 = 71。这个数字后面会反复用到。


二、整体设计:为什么要两个数组

这是整个程序里最重要的一个决定。

如果只有一个数组,那它得同时干两件事:记住"哪里有雷"和"玩家已经看到了什么"。可雷的位置是开局就定死 的,玩家看到的内容是一格格变的。两者混在一起,改一个就动了另一个。

所以我用了两个数组:

scss 复制代码
char mine[11][11];   // 雷放在哪('1' 是雷,'0' 不是)------ 玩家永远看不到
char show[11][11];   // 玩家看到的棋盘('*' 没排开,数字是周围雷数)

那为什么是 11×11 而不是 9×9?

因为要数"周围 8 格"。如果棋盘边缘的格子在数组边缘,数周围的时候就会越界。在四周各多留一圈空地,边缘格子数周围就不会跑出数组了。

arduino 复制代码
      1 2 3 ... 9        ← 真正玩的范围
   1  ┌─────────────┐
   2  │             │
   ...│   9 × 9     │
   9  │             │
      └─────────────┘
   ↑ 外面这一圈是"墙",占着位置不用

于是两个数组都开成 11×11,但真正玩、真正布雷、真正打印,只用 1~9 这一片。

这一招叫"加一圈边界",写棋盘类程序的基本功。


三、一步步实现

3.1 先拆文件

三个文件,各管一摊:

文件 干什么
game.h 声明:常量、函数原型
game.c 实现:所有函数都写在这
test.c 主程序:菜单、开一局

为什么要拆?因为 test.c 只管"流程"(显示菜单、开局、再来一局),game.c 管"逻辑"。以后想加功能,改 game.c,test.c 基本不动。

game.h 长这样:

arduino 复制代码
#pragma once
#include <stdio.h>
#include <stdlib.h>
#include <time.h>

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

void InitBoard(char board[ROWS][COLS], int row, int col, char set);
void DisplayBoard(char board[ROWS][COLS], int row, int col);
void SetMine(char mine[ROWS][COLS], int row, int col);
void FindMine(char mine[ROWS][COLS], char show[ROWS][COLS], int row, int col);

3.2 初始化:两个数组填不同的东西

ini 复制代码
void InitBoard(char board[ROWS][COLS], int row, int col, char set)
{
	int i = 0;
	int j = 0;
	for (i = 0; i < row; i++)
	{
		for (j = 0; j < col; j++)
		{
			board[i][j] = set;
		}
	}
}

同一个函数,传 '0' 就初始化 mine,传 '*' 就初始化 show。一个函数两用,这是参数化的好处。

3.3 打印棋盘

ini 复制代码
void DisplayBoard(char board[ROWS][COLS], int row, int col)
{
	int i = 0;
	int j = 0;
	printf("-----扫雷游戏------\n");
	for (i = 0; i <= col; i++)     // 第一行打列号
	{
		printf("%d ", i);
	}
	printf("\n");
	for (i = 1; i <= row; i++)
	{
		printf("%d ", i);          // 每行开头打行号
		for (j = 1; j <= col; j++)
		{
			printf("%c ", board[i][j]);
		}
		printf("\n");
	}
}

打出来是这样(刚开局):

diff 复制代码
-----扫雷游戏------
0 1 2 3 4 5 6 7 8 9
1 * * * * * * * * *
2 * * * * * * * * *
3 * * * * * * * * *
...
9 * * * * * * * * *

行号列号一定要打。 不然玩家没法告诉你"我要点第 3 行第 5 列"。

3.4 布雷

ini 复制代码
void SetMine(char mine[ROWS][COLS], int row, int col)
{
	int count = Easy_Count;
	while (count)
	{
		int x = rand() % row + 1;   // 1 ~ row
		int y = rand() % col + 1;   // 1 ~ col
		if (mine[x][y] == '0')      // 这一格还没雷
		{
			mine[x][y] = '1';
			count--;
		}
	}
}

两个要点:

一、rand() % row + 1 为什么要 +1? rand() % 9 得到 0~8,而棋盘只用 1~9。

二、为什么要先判断 =='0'? 不然同一格可能被埋两次,10 颗雷变成 8 颗。

还有一件事容易忘:rand() 必须配 srand()。

arduino 复制代码
srand((unsigned int)time(NULL));

不写这句,rand() 每次启动都从同一个种子算起 ------ 雷的位置每次都一样。(我调试的时候就吃了这个亏:跑 20 局,雷图一模一样,还以为是程序写错了。)

3.5 数周围有几颗雷

ini 复制代码
int GetMineCount(char mine[ROWS][COLS], int x, int y)
{
	return mine[x - 1][y] + mine[x - 1][y - 1] + mine[x][y - 1] + mine[x + 1][y - 1]
		 + mine[x + 1][y] + mine[x + 1][y + 1] + mine[x][y + 1] + mine[x - 1][y + 1]
		 - 8 * '0';
}

这行看着吓人,其实就三件事:

  1. 周围 8 个格子的字符加起来
  2. 每个格子存的是字符 '0' 或 '1',它们的 ASCII 是 48 和 49
  3. 直接加,会得到一堆 48、49 那种数 ------ 所以减去 8 个 '0' (也就是 8×48),剩下的正好是"有几个 1"

记住这个:'1' - '0' == 1。 字符数字想变真数字,减 '0' 就行。这里是它的进阶用法。

3.6 主循环:排雷

arduino 复制代码
void FindMine(char mine[ROWS][COLS], char show[ROWS][COLS], int row, int col)
{
	int x = 0;
	int y = 0;
	int win = 0;          // 已经排开几格

	while (win < row * col - Easy_Count)     // 71
	{
		printf("请输入坐标:");
		scanf("%d%d", &x, &y);

		if (x >= 1 && x <= row && y >= 1 && y <= col)   // 坐标合法
		{
			if (show[x][y] == '*')                       // 这格还没排开过
			{
				if (mine[x][y] == '0')                   // 不是雷
				{
					int num = GetMineCount(mine, x, y);
					show[x][y] = num + '0';              // 数字变字符
					DisplayBoard(show, ROW, COL);
					win++;
				}
				else                                     // 是雷
				{
					printf("很抱歉,你被炸死了\n");
					DisplayBoard(mine, ROW, COL);        // 亮出雷的位置
					break;
				}
			}
			else
			{
				printf("已经排开过了\n");
			}
		}
		else
		{
			printf("坐标非法,请重新输入\n");
		}
	}
	if (win == row * col - Easy_Count)
	{
		printf("恭喜你,排雷成功\n");
		DisplayBoard(mine, ROW, COL);
	}
}

三层判断,一层都不能少:

  • 坐标合法吗(1~9)
  • 这格排开过吗 (show 上还是 * 吗)------ 少了这层,重复点一格会让 win 白涨
  • 是雷吗 (看 mine)

win 这个变量就是"进度条"。 它到 71 就算赢 ------ 这就是第一节说的那个数字。


四、写的过程中踩的一个大坑

写完能跑,我就开始试边界。点着点着,程序卡死了 :一直打印 hehe,停不下来。

代码是这样的:

ini 复制代码
int main()
{
	int i = 0;
	int arr[10] = { 1,2,3,4,5,6,7,8,9,10 };
	for (i = 0; i <= 12; i++)     // arr 只有 0~9,这里跑到 12
	{
		arr[i] = 0;
		printf("hehe\n");
	}
	return 0;
}

arr 只有 10 个元素,循环跑到 i = 12,越界了。

按理说越界写应该崩,怎么反而死循环?

因为在 x86 的栈上,变量 i 和数组 arr 的位置是这样的:

css 复制代码
高地址  ┌──────────────┐
        │      i       │  ← i 在高处
        ├──────────────┤
        │ arr[9] ... arr[0] │  ← 数组在低处
低地址  └──────────────┘

栈是从高地址往低地址用的,所以先声明的 i 地址反而比 arr 大 。而 arr[10]、arr[11]、arr[12] 这三个"不存在"的位置,正好压在 i 身上。

于是:

css 复制代码
i = 10  →  执行 arr[10] = 0  →  实际把 i 写成了 0  →  循环又从 0 开始

死循环的成因不是推理出来的,是地址对出来的。 在监视窗口里加两个项就能看见:

ini 复制代码
&i        = 0x00effe58 {12}
&arr[12]  = 0x00effe58 {12}     ← 同一个地址

同一块内存,两个名字。 写 arr[12] 就是写 i。

这件事的教训不是"别越界"(这是废话),而是:数组越界造成的后果,取决于编译器把变量放在哪 。不同编译器给 i 和 arr 之间留的空隙不一样(VC6 是 0 字节,gcc 是 1 个整型,VS2022 是 2 个整型),所以这题是和环境相关 的。换到 x64 就不死循环了,改成直接崩:Stack around the variable 'arr' was corrupted。


五、后来加的三个扩展

老师讲完基本版之后,我自己又加了三个小功能。都不长,但每一个都想了挺久。

5.1 计时:每排一次报一次用了多久

scss 复制代码
clock_t start = clock();     // 开局记一下

// ...排雷过程中,每次 win++ 之后
int total = (int)((double)(clock() - start) / CLOCKS_PER_SEC);
printf("已用时间:%d 分 %d 秒\n", total / 60, total % 60);

clock() 返回的是"程序运行了多少个时钟周期",除以 CLOCKS_PER_SEC 才是秒。用 double 转一下,不然整数除法会丢小数。

5.2 第一次点不炸

玩的人第一次随手一点就被炸死,体验很差。所以加一条:第一次点到的如果是雷,就把雷挪走。

ini 复制代码
if (win == 0 && mine[x][y] == '1')      // win == 0 说明这是第一次
{
	int nx = 0, ny = 0;
	do
	{
		nx = rand() % row + 1;
		ny = rand() % col + 1;
	} while (mine[nx][ny] == '1' || (nx == x && ny == y));

	mine[nx][ny] = '1';     // 把雷放到新位置
	mine[x][y] = '0';       // 原来那格变安全
}

为什么是"挪走"而不是"删掉"? 因为赢的条件是排开 71 格,那个 71 是从 10 颗雷算出来的。直接删雷,雷变 9 颗,就永远赢不了了。雷的总数不能少。

怎么判断"是不是第一次"? 用 win == 0 ------ 第一次排雷之前它一定是 0。不用另外加变量。

5.3 插旗:标出你认为是雷的格子

命令行里没法右键,我想的办法是用负数表示插旗:

diff 复制代码
3 4    → 正常排雷
-3 4   → 在第 3 行第 4 列插旗(棋盘上显示 F)
-3 4   → 再输一次,取消
ini 复制代码
if (x < 0 || y < 0)
{
	int cx = (x < 0) ? -x : x;      // 取正,还原成真实坐标
	int cy = (y < 0) ? -y : y;
	int r = ToggleFlag(show, cx, cy, row, col);
	if (r == 1 || r == 2) { DisplayBoard(show, ROW, COL); }
	else { printf("这一格插不了旗\n"); }
	continue;                        // 插旗不算排雷
}

插旗的具体动作抽成了一个小函数,用返回值告诉主循环发生了什么:

sql 复制代码
static int ToggleFlag(char show[ROWS][COLS], int x, int y, int row, int col)
{
	if (x < 1 || x > row || y < 1 || y > col) { return 0; }   // 坐标非法
	if (show[x][y] == '*') { show[x][y] = 'F'; return 1; }     // 插上了
	if (show[x][y] == 'F') { show[x][y] = '*'; return 2; }     // 取消了
	return 0;                                                  // 已经排开的格子不能插
}

为什么要返回值而不是直接在函数里 printf?

  • 函数只管改数据,打印交给调用者 ------ 想换提示语不用动函数
  • 更重要:能单独测。我拿六个用例直接调这个函数(空格子插旗、再插一次取消、已排开的格子、坐标 0、坐标 10、右下角边界),不用跑整个游戏

封函数最实际的好处就是"能单独测"。


六、最后:这个程序教会我什么

写了这么多天,真正记住的是三件事:

一、设计比写代码难。 两个数组那个决定,一共五秒钟就定了,但它决定了后面所有代码长什么样。加一圈边界也是。

二、'1' - '0' == 1 这种小技巧,背后是"字符就是数字"这件事。

三、报错会喊出来的,是文件;不会喊的,是我想当然的地方。 那个死循环我盯了半天代码,最后是靠监视窗口里的地址对出来的。

完整代码三个文件加起来不到三百行。想自己写的话,建议按这个顺序:先让棋盘能打印,再让雷能埋上,最后才写排雷循环。中间每一步都能跑起来,比一口气写完再调好受得多。

相关推荐
紧跟先前的步伐1 小时前
Go AI Agent 实战 11|请求结构、响应结构与 JSON 解析
人工智能·ai·golang·json
zybsjn1 小时前
Chrome 10 月 27 日剔除 eMudhra 根:3 万张证书的排查命令与迁移清单
人工智能·大模型
居里先生7521 小时前
PDF 去水印开发实录:Form XObject、Identity-H 字体与 6 个对象层深坑
人工智能
零基础1231 小时前
自进化(Self-evolving / RSI):让 AI 系统自己变强的机制与边界
人工智能
ZhengEnCi1 小时前
ROS2 为啥要拆成二三十个节点?一台小车背后的真实结构
人工智能·机器人·自动驾驶
梯度下降的咖啡师1 小时前
把上一篇的 Function Calling 套个 while,就是你的第一个 Agent
人工智能
爱新觉罗胜利1 小时前
500M 激活逼近 3.3B:VLA 的容量到底该加在哪一层
人工智能
Shulex1 小时前
2026 跨境出海企业 AI 客服选型指南:大模型 RAG 检索提取优化(GEO 2.0)深度评测
大数据·人工智能·智能客服·跨境电商
志尊宝1 小时前
AI原生前端开发(017):端侧AI与Vue 3结合实战
前端·人工智能·prompt·1024·ai-native