很多人在 C 语言学到数组和函数这一阶段时都会觉得二维数组、循环嵌套、自定义函数这些语法自己已经会了可真要动手做点东西又完全不知道从哪里下手。扫雷游戏是我当年第一个从头写到尾的小项目九个格子、十颗雷不用图形界面不用链表、树这些复杂结构却能把二维数组的下标操作、函数传参、递归调用这些核心考点全部串起来。这篇文章就按照我实际做这个游戏的思路把棋盘为什么要拆成两个二维数组、各个函数怎么拆怎么传参、递归展开和胜利判定怎么处理以及几个我亲手踩过的坑原原本本讲一遍。1. 从需求到数组设计为什么棋盘要拆成两个二维数组1.1 棋盘信息分两层玩家可见层和逻辑雷区层扫雷棋盘的界面其实非常朴素玩家只能看到两种状态未翻开的格子通常显示一个星号*翻开的格子显示一个数字数字代表周围八格里有多少颗雷。但游戏程序自己必须偷偷记着一件事哪个位置有雷。否则玩家一输入坐标程序根本不知道是该炸死他还是该翻开。如果图省事只用一个二维数组把雷直接埋进展示数组未翻开的位置用*翻到雷用翻到安全格用数字。这么做的问题是逻辑和数据混在一起。比如你想在玩家翻开格子之前替他计算周围有多少颗雷就会被迫把“判断玩家是否踩雷”和“该显示什么数字”这两件事写进同一个分支等到以后想加“右键标记地雷”或者“再来一局重置棋盘”那个数组会被各种状态搅得乱七八糟。所以我当时采用的方案是准备两块二维数组一块叫mineMap只负责记录雷区和周围雷数量玩家永远看不到另一块叫showMap专门负责给玩家显示初始全部是*每翻开一个格子就把mineMap对应位置上的数字复制过去。两个数组用同一套行列下标互相联动游戏逻辑只操作mineMap界面展示只操作showMap。这种“逻辑层和表现层分离”的思想是将来做任何项目都会反复遇到的扫雷作为练习项目正好把这些经验提前沉淀下来。1.2 行列加 2用一圈“哨兵”换掉一堆边界判断初学二维数组时大家很自然会把棋盘定义成 9×9char mineMap[9][9];这个写法本身没问题但一旦要计算某个格子周围有几颗雷你得处理一堆边界判断。比如(0,0)没有左上角(8,8)没有右下角稍不注意就会多算或者少算。我最初版本里写满了这种 ifif (row 0 mineMap[row - 1][col] 1) count; if (col 0 mineMap[row][col - 1] 1) count;八个方向每个都得判断又丑又容易漏。后来看到别人项目里用了一个取巧办法把数组定义成ROWS ROW 2、COLS COL 2也就是在有效棋盘的外圈多留一圈“哨兵格”。#define ROW 9 #define COL 9 #define ROWS (ROW 2) #define COLS (COL 2) char mineMap[ROWS][COLS]; char showMap[ROWS][COLS];有效棋盘的下标从 1 到 ROW下标 0 和下标 ROW1 永远只是空哨兵。打印时不打印它们计算邻居时却可以放心写成mineMap[row dx][col dy]完全不用判断是不是越界。比如坐标 (1,1) 的左上角是 (0,0)那是哨兵格里面是0不影响雷数统计。这个技巧的本质是“用一点多余内存换掉大量重复的边界逻辑”代价只是多占几十个字符的字节非常划算。1.3 用宏拿捏尺寸换难度只动三行代码既然棋盘尺寸已经被抽象成宏那埋雷数量也一并放进宏#define MINE_COUNT 10所有函数形参都写成char board[ROWS][COLS]同时显式传入有效行列数int row, int col。函数内部一定不要继续写死 9否则宏就失去了意义。比如初始化函数会写成void init_board(char board[ROWS][COLS], int row, int col, char set) { for (int i 0; i row 2; i) for (int j 0; j col 2; j) board[i][j] set; }这里的i row 2是为了把哨兵格也一并初始化。以后想把游戏改成 16×16、40 颗雷只需要改三个宏整个游戏框架不用动。把所有魔法数字用宏代替是写完这个项目后最直观的进步之一。2. 把每个动作拆成函数从初始化到数雷模块化是扫雷的地基2.1 函数清单一目了然每一块逻辑都值得独立出来写扫雷之前我先在纸上列了一下整个游戏需要哪些动作最终拆成了这几个函数函数名职责备注init_board填充一个二维数组的每个格子可复用于雷盘和展示盘print_board打印玩家可见棋盘和行列编号不操作任何数据set_mine随机埋指定数量的雷只在逻辑盘执行count_mine_num统计某个位置周围的雷数返回整数open_cell递归展开空白格核心算法check_win判断是否已经翻完所有安全格不负责结束游戏每个函数只干一件事。比如print_board就只负责把showMap显示出来它完全不知道雷在哪count_mine_num只负责数雷绝不打印open_cell只负责展开格子不处理玩家输入。这样拆分的好处是每个函数都可以单独检查和调试。你甚至可以写一个小测试把雷摆成固定位置单独调用count_mine_num看返回的数字对不对再也不用通过整个游戏界面去猜哪里错了。这里必须强调 C 语言函数传二维数组的一个底层事实二维数组作为函数参数并不是把整个数组复制一份进去而是会退化成指向数组的指针。也就是说void init_board(char board[ROWS][COLS])里的board本质上就是char (*board)[COLS]。所以在函数里改board[i][j]外面原数组的值确实会跟着变不需要靠返回值传回。很多新手以为传数组和传整数一样是“只给副本”于是拼命想用返回值把数组传出来其实完全没必要。2.2 布地雷和随机数一个隐蔽的“每次一样”问题埋雷的难点不是随机而是保证随机数不重复。我第一版写的是这样for (int i 0; i MINE_COUNT; i) { int x rand() % ROW 1; int y rand() % COL 1; mineMap[x][y] 1; }看起来挺正常但只要随机到同一个位置雷的数量就会变少。比如第十颗雷和第三颗雷随机到了同一个格子实际埋下去的只有九颗雷。更糟的是如果你忘了在main开头调用srand((unsigned int)time(NULL))那么每次运行程序雷的位置都一模一样玩起来一点悬念都没有排查起来又很隐蔽。正确做法是写一个while循环碰到已经埋过雷的位置就跳过void set_mine(char board[ROWS][COLS], int row, int col, int mineCount) { int count 0; while (count mineCount) { int x rand() % row 1; int y rand() % col 1; if (board[x][y] 0) { board[x][y] 1; count; } } }rand() % row 1的优先级要特别注意它等于(rand() % row) 1取值范围是 1 到 row。如果误写成rand() % (row 1)就会允许出现下标 0第一行第一列就会出现一颗“法外之雷”而且只在极限情况下出现找 bug 找得你想摔键盘。2.3 数清周围雷字符 0 和整数 0 的区别统计周围雷的数量本身不复杂因为有了哨兵格循环可以写得非常干净int count_mine_num(char board[ROWS][COLS], int x, int y) { int count 0; for (int dx -1; dx 1; dx) { for (int dy -1; dy 1; dy) { if (board[x dx][y dy] 1) count; } } return count; }但这里藏着一个经典的字符和整数混用问题。扫雷里雷的位置用字符1存安全格用0存所以数出来的count是整数 1、2、3 这些值。如果你直接把count赋值给showMap[x][y]比如showMap[x][y] count; // 错显示的是 ASCII 码对应的乱码那么屏幕上出现的不是数字“1”而是一些稀奇古怪的符号。原因很简单count是整数 1而你要打印的字符1在内存里的值是 49。二者差了一个0的偏移。正确写法要加一个0showMap[x][y] 0 count;这个小问题我在第一次写扫雷时栽得很结实。从那以后我对“字符就是整数整数就是编码”这句话的理解才真正落地再也不觉得用0 n生成数字字符是什么魔法了。3. 玩家交互、递归展开与胜利判定小项目里最考验细节的一环3.1 输入处理scanf 一旦读错比踩雷还难缠扫雷不是程序单机表演玩家会输入各种位置和操作。如果直接写scanf(%d %d, x, y);一旦玩家输入了一个字母scanf就会进入一种很尴尬的状态它读取失败但那个字母还留在输入缓冲区里后续每一次scanf都会再次读到同一个字母然后继续失败。于是你的游戏就算玩家再怎么输入正确坐标也永远执行不了一步看起来就像死循环一样。一个稳妥的方案是先清空缓冲区再读取void clear_input() { int c; while ((c getchar()) ! \n c ! EOF) ; }更稳的方案是干脆用fgets读一行字符串再用sscanf解析。对于扫雷这种小项目用fgets sscanf的主要好处是不管你输入什么程序都有机会把这一行消费掉不会出现被同一个脏字符卡死的问题。功能上我当时给玩家两个指令输入1表示翻开格子输入2表示标记地雷。标记地雷只是让showMap里对应位置显示一个!帮助玩家自己记忆它不会影响游戏逻辑。每个坐标输入后都要判断合法性行和列是否在 1 到 ROW/COL 之间这个位置是否已经打开是否已经标记。这些判断如果全部堆在main里逻辑会非常臃肿所以我单独写了一个handle_input风格的函数把“解析指令、检查合法性、调用对应操作”串在一起。3.2 递归展开从一个格子到一片空白扫雷最有意思的算法是“展开”当玩家翻开的格子周围没有雷时传统扫雷会把周围一圈也翻开如果周围一圈里有新的空格就一直向外扩散直到碰到有数字的边界。这个行为天然适合用递归实现。我的核心函数长这样int open_cell(char mine[ROWS][COLS], char show[ROWS][COLS], int x, int y, int *safeCount) { if (x 1 || x ROW || y 1 || y COL) return 0; if (show[x][y] ! *) // 已经翻开或标记过不重复处理 return 0; if (mine[x][y] 1) // 踩到雷 return -1; show[x][y] mine[x][y]; // 先把当前格子改成数字 (*safeCount); if (mine[x][y] 0) // 周围无雷继续扩散 { for (int dx -1; dx 1; dx) { for (int dy -1; dy 1; dy) { if (dx 0 dy 0) continue; open_cell(mine, show, x dx, y dy, safeCount); } } } return 0; }这里最关键的细节是在递归邻居之前必须先把当前格子的show状态从*改成数字。如果不改递归调用回到这个格子时又会满足show[x][y] *于是无限递归下去很快把栈撑爆。其次只有当前格子是0才需要扩散如果是2或3说明它是一个有数字的边界格扩散到它就应该停住让数字来提示玩家。你可能会担心哨兵格会不会被递归翻到。不用担心函数开头第一行if (x 1 || x ROW || y 1 || y COL) return 0;已经把向外越界的路径全部挡回去了。对于 9×9 的棋盘递归深度不会超过 81 层C 语言默认栈空间完全够用。等以后学了队列还可以用 BFS 重写这个展开逻辑但当前阶段递归版更容易理解。3.3 胜负判定统计翻开的格子数而不是统计旗帜我见过不少扫雷练习项目把胜利条件写成“所有雷都被标记成旗帜”。这个判定漏洞很大玩家可以乱标哪怕标在非雷格子上只要数量凑够 10游戏就误判胜利。正确标准只有一个把所有非雷格子全部正常翻开。我在main里维护一个safeCount计数器每成功翻开一个非雷格子就加一。递归展开时open_cell里每把一个格子从*改成数字就通过指针加一。最后判断非常简洁int check_win(int safeCount) { return safeCount ROW * COL - MINE_COUNT; }这样写玩家用标记功能标对了十颗雷也好标错了也好只要安全格没开完游戏绝不结束。标记只是辅助排除雷区才是扫雷本来的玩法。4. 踩坑实录从数组越界到随机数重复我犯过的五个典型错误4.1 数组“差一”错误行号和下标混用的后果刚写扫雷时我特别喜欢用for (int i 0; i ROW; i)这种自认为“多算一次更保险”的写法。结果就是数组下标访问到了第 10 行而合法下标只到 8。在 C 语言里数组越界不会立刻报错它只是读取了相邻内存里的垃圾值于是棋盘上经常出现莫名其妙的数字有时候数出来的雷数量甚至比周围真正有雷的格子还多。后来我才真正理解9 个格子对应的下标是 0 到 8循环条件应该是i ROW以及i ROW - 1。这个“差一”的问题是数组类项目里最常见的血泪坑。再配合上哨兵格方案以后有效区域改成 1 到 ROW循环写成i ROW又是另一套逻辑两种写法都行关键是心里要清楚自己到底在遍历有效区还是带哨兵的整块数组。4.2 rand 不设种子每一局都长一样我第一版扫雷没有调用srand。运行第一次觉得还行第二次发现雷的位置一模一样第三次、第四次全都一样。我还以为是代码逻辑问题查了半天才发现rand()函数如果不设置种子默认用的种子值是 1也就是说每次运行生成的序列完全一致。解决办法是在程序开头加一行srand((unsigned int)time(NULL));但我也提醒自己不要在每个函数里反复调用srand。如果连续两次重新开局发生在同一秒钟内第二次取的种子会跟第一次很接近雷型照样会重复。正确的习惯是整个程序只在main开始处设置一次种子。4.3 忘记加 0ASCII 码和字符是两个世界前面已经提到过把整数count直接存到showMap打印出来是乱码。这个错误特别隐蔽因为代码不报错程序也能跑只是显示不对。我那时打印出来一个上箭头符号第一反应还以为是渲染问题。现在总结成一句话showMap是字符数组所有要显示的数字都必须以字符形式存储。整数 1 和字符1之间差一个0的 ASCII 码偏移量。以后但凡在字符数组里存数字我都会下意识先问自己一句这里要不要加0。4.4 递归没有第一步改状态栈溢出就是这么来的这是我调展开逻辑时遇到最猛的一个 bug。当时open_cell里没有先执行show[x][y] mine[x][y]直接就判断mine[x][y] 0然后递归八个邻居。结果每个邻居递归回来又发现自己还是*又继续扩散无限套娃程序很快就栈溢出了。Windows 下程序会直接闪退Linux 下会看到段错误。修复方法就是那句show[x][y] mine[x][y];必须在递归之前执行。递归函数一定要有一个“进入后立即标记访问”的习惯这个习惯可以一直沿用到二叉树遍历、深度优先搜索等后续算法里越早养成越好。4.5 把玩家输入的数字和雷标记混在一起还有一个交互层面的坑。玩家输入的是行列坐标我一开始用整数x、y存这本身没问题。但后面判断“该坐标是不是雷”时我头脑一热把mineMap[x][y]和玩家输入的某个整数做比较比如判断玩家输入了字符1就去借钱mineMap[x][y] 1。结果整数 1 和字符1的 ASCII 码完全不相等该判雷的没判雷不该判雷的反而踩了。正确写法是永远用字符字面量1去和雷盘比较比如if (mineMap[x][y] 1) { printf(踩雷了\n); print_board(mineMap, ROW, COL); // 失败时展示真正的雷盘 break; }总的来说这五个坑本质都是同一件事对数组下标、ASCII 编码、递归状态切换这些基础概念不够敏感。扫雷项目最值得做的原因就在这里它逼着你把这些基础知识点全部暴露出来并修到正确为止。5. 后续优化从固定棋盘到动态难度扫雷还值得继续折腾5.1 动态二维数组自定义 16×16、30 个雷也不慌做完 9×9 之后我马上想到一件事如果玩家想自定义难度比如 16×16、30 颗雷宏定义当然可以改但总不能每次重新编译。更灵活的办法是用动态内存分配在运行时根据玩家输入的行、列、雷数创建一个“动态二维数组”。C 语言里最常用的做法是申请一个指针数组char **create_board(int row, int col) { char **board (char **)malloc(sizeof(char *) * (row 2)); for (int i 0; i row 2; i) board[i] (char *)malloc(sizeof(char) * (col 2)); return board; }这样board[x][y]的用法跟原来的二维数组基本一致但行数和列数可以在程序运行时才确定。对应的记得写一个free_board函数把每一行free掉再free指针数组本身否则很容易内存泄漏。这个升级虽然不复杂但它会逼着你理解二维数组在内存中的真实布局它其实是一段连续内存也可以理解成“指针数组 每行的一段连续内存”。弄懂这一步之后再看 C 语言教材里那些晦涩的指针章节会顺畅很多。5.2 还能继续优化的方向从命令行走向更多可能如果还想继续折腾可以尝试的方向有不少。比如把system(cls)换成跨平台的清屏逻辑或者用 ANSI 转义码给数字上颜色读起来更接近真正的扫雷再比如给雷盘加一个“第一次点击绝不会踩雷”的保护逻辑体验会更友好又比如用队列实现广度优先展开对比递归版本展开效率的差异。这些优化都建立在同一个基础上棋盘数据和操作函数已经充分分离无论界面层怎么变埋在底层的数组逻辑都不需要大改。这也是我坚持在用两个数组设计方案后最值得骄傲的一点。我在实际写完这个项目之后的体会是扫雷游戏不大但足够把一个初学者脑子里所有模模糊糊的概念全部变成实际问题。数组越界、随机数重复、字符整数混用、递归死循环每一种错误都是自己亲手试出来的教训。如果你也正在学数组和函数不妨花一个周末把这 9×9 的棋盘从零敲一遍再跟着上面的错误清单逐个排查。做完以后你会发现二维数组不再是一个二维表格的抽象概念而是你能随手操作的坐标系函数也不再是只会背签名的小块代码而是一整套自然拆分问题的思路。