前言
上一篇写完分支和循环,我在结尾留了个点:那些 if、switch、while、break、continue、goto 单独学的时候都挺好懂,可它们凑一块儿能干嘛?
所以这篇来了。
Windows 控制台版扫雷。 9×9 的棋盘,输入坐标翻格子,踩雷就死,翻完就赢,还能选难度 (雷的数量喵) 。
先说清楚这是个很初阶的版本,免得看到一半骂我喵 :这不是 Windows 自带那个扫雷。没有鼠标,不能插旗,点开一片空白也不会自动展开,第一下就可能踩雷。但它是我从空白文件一个字一个字敲出来的,能跑,能输能赢------对我来说已经很有成就感了(当然后续会继续改进)。
很简单的菜单页...
一、先想明白扫雷
- 棋盘里随机藏一堆雷,玩家看不见。
- 你输入一个坐标,翻开那个格子。
- 是雷就结束;不是雷,就告诉你周围八个格子里有几颗雷。
- 所有没雷的格子都翻开了,你就赢了。
规则就这么点。顺手查了查游戏背景(因为我很喜欢玩扫雷呢),扫雷是微软两位工程师 Curt Johnson 和 Robert Donner 在 1990 年做的,1992 年跟着 Windows 3.1 一起发出去,把系统里原来的黑白棋给挤走了。记得我小学电脑课就偷偷玩扫雷 ,现在自己写一遍,感觉还挺爽。
来源:百度百科「扫雷」词条。
二、两张棋盘,but whay?
这是我写扫雷之前很久没有想到的地方。
我一开始想,一张二维数组不就装下了吗?后来才反应过来------程序知道雷在哪,玩家不能知道。 只有一张表的话,每次打印都得判断"这格能不能给他看"。想想就头大喵 。
所以是两个数组:
c
char mine[ROWS][COLS]; // 后台:'0' 不是雷,'1' 是雷
char show[ROWS][COLS]; // 前台:一开始全是 '*',翻开后变成周围雷数
你可以把它想成上下两张纸:
markdown
玩家看到的 show 程序保存的 mine
* * * * 0 0 1 0
* * * * 0 0 0 0
* 2 * * 1 0 0 0
* * * * 0 0 0 1
平时只给你看上面那张。只有两种时候才会把下面那张翻出来:你踩雷了 (让你死个明白喵) ,或者你赢了(让你看看雷都藏哪)。
运行扫雷游戏界面
三、棋盘应是 9×9,数组我却开了 11×11
这个小技巧是我这次学到的最聪明的东西 (好像让我开智了一样) ,也是我第一次体会到"多开点空间换代码简单"是什么感觉。
c
#define ROW 9
#define COL 9
#define ROWS ROW+2
#define COLS COL+2
数组实际 11×11,但你能输入的坐标还是只有 1~9。第 0 行、第 10 行、第 0 列、第 10 列都不是游戏区,全填 '0'。
橙色是辅助格(永远是 '0'),蓝色是真正的 9×9 游戏区域。
为什么要多一圈? 因为数一个格子周围的雷,要访问它周围 8 个位置。你选的是角落 (1,1),代码照样会去访问 (0,0)。多出来这一圈,保证了这些访问永远落在数组里,不会越界。
代价是几十个 char。好处是不用写八个 if 去判断"我是不是在边缘"。
(太划算了,属于那种"知道之前觉得很麻烦,知道之后再也回不去"的东西了喵)
四、难度选择是我自己加的 (顺便打了自己的脸喵)
说实话基础扫雷不一定要有难度选项。我加它的理由特别朴素------上一篇刚学完 switch,我想找个地方再用一次,就为了练习了。
三档只改雷数,棋盘大小不变 (懒了) :
| 难度 | 雷数 | 要翻开多少格才赢 |
|---|---|---|
| Easy | 10 | 71 |
| medium | 15 | 66 |
| hard | 20 | 61 |
那个"要翻开多少格"就是 9 × 9 - 雷数。Easy 是 81 减 10 等于 71,翻完这 71 格就赢。后面判断胜负用的就是这个。
然后是我打自己脸的地方:
上一篇讲 goto 的时候我写的是"能不用就别用,跳来跳去难维护"。然后这次......我用了,还用了两次:
c
again:
printf("请输入0或1或2:>");
if (scanf("%d", &n) != 1)
{
printf("输入不是数字喵!只能输入0或1或2!\n");
while (getchar() != '\n');
goto again;
}
原因是这里的场景就是"输错了,回去重输"。用 again: 加 goto 写起来最短,读起来也最清楚,比包一层 while 再配标志变量干净多了 (说白了还是懒) 。不是不能用,是不该拿它去搭主逻辑。 像这种"错了重来"的小范围回跳,反而最直白。
其次是这段一开始其实我还忘了做防止输入字母的防护 (scanf输入的是int类型),给别人玩出来bug了,后来紧急修复了,所以这一块儿也是为了方便,紧急写的 (流汗了喵)
c
while (getchar() != '\n');
清理字母还是太好用了喵!
("输入不是数字喵!只能输入0或1或2!\n")
五、数周围有几颗雷 (绕圈圈来了)
就是这个:
c
int AroundBoob(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';
}
八个方向全加一遍,最后减 8 * '0'。
第一次看到末尾那个 - 8 * '0' 我是懵的,什么鬼。后来明白:我数组里存的是字符 '0' 和 '1',不是数字 0 和 1。字符 '0' 的 ASCII 是 48,'1' 是 49。
八个格子直接加,就算一个雷都没有,结果也是 48 × 8 = 384,不是 0。
所以每个格子都要减掉一个 '0':
'1' - '0'= 49 - 48 = 1(有一个雷)'0' - '0'= 48 - 48 = 0(没雷)
八个的 '0' 一次性减掉,就是 - 8 * '0'。剩下的正好是雷数。
想通之后我还挺兴奋的喵 。字符和数字之间就差一个 '0'。 后面把数字放回棋盘给玩家看,也是同一个套路:
c
show[x][y] = num + '0'; // 数字 3 变成字符 '3'
顺带说一句,这段代码能这么"粗暴"地直接访问八个方向而不用担心越界,靠的就是第三节那一圈辅助格。要是数组只有 9×9,这里得写八个 if 判断边界,光想想就烦。
深色是当前翻开的格子,浅色是要统计的 8 个邻居。
六、布雷:随机 + 判重复
c
int Boob = count;
while (Boob != 0)
{
int x = rand() % row + 1;
int y = rand() % col + 1;
if (board[x][y] == '0')
{
board[x][y] = '1';
Boob--;
}
}
思路就是摇号:随机摇个坐标,那格还是 '0'(没有雷喵) 就放雷,待放数量减一;摇到重复的就作废重摇。
有两个地方很重要喵:
** 首先+ 1 不能漏。** rand() % 9 出来的是 08,加 1 才是你能用的 1 9。少了它+ 1,雷就跑到辅助格子里去了喵,棋盘上就一个雷都没有了。
判重。 随机可能是会重复的。不判断的话 Boob-- 照样执行,表面上放了 10 颗,实际棋盘上可能只有 7 颗。这个 bug 特别阴------程序不报错,只是难度悄悄变简单了,你得自己调试一遍才发现喵。
另外 srand((unsigned int)time(NULL)) 只在 main 开头调一次。rand() 是伪随机,不播种的话每次运行都是同一串数,雷的位置永远一样,玩两局就没意思了。
来源:
rand/srand用法参考 cppreference。
七、清屏这件小事,得谢谢另外一位博主 (bug也是他玩出来的,谢谢喵)
我最早的版本没清屏,每翻一次格子就在下面再打一张棋盘。终端里堆一长串历史,翻三次你就不知道自己在哪了 体验非常糟糕了喵 。
后来看到 CSDN 上黑暗的丹 写的《从"一碰就碎"到"铜墙铁壁":大一新生的 C 语言扫雷实战复盘》,里面用了清屏,我照着试了下:
c
void DisPlayBoard(char board[ROWS][COLS], int row, int col)
{
system("cls");
/* 然后才打印当前棋盘 */
}
加上之后画面就变成"同一块棋盘在原地更新"了,观感一下子好一大截。那篇写得挺细,还讲了递归展开,推荐去看看。不过我这篇讲的就是我自己这个基础版。人家做到那个确实很棒。
清屏有个坑(可能第一次用不太会喵) :cls 会把之前打印的提示也一起清掉。所以我把玩法提示放在每次要坐标的时候打印,而不是循环外面打一次。顺序弄反了就会出现"明明打印了提示,眼前却什么都没有"的诡异情况------程序没报错,但玩家会很迷惑。
(
cls和Sleep都是 Windows 的东西,所以这代码是 Windows 专属的,拿到 Linux 上得换成clear和usleep。)
找雷流程
八、依旧我踩过的坑,和还可以改进的
踩过并解决了的:
ROWS和ROW传反了(思路想了好久)。 初始化要覆盖整个 11×11,用ROWS/COLS;布雷打印排雷只管 9×9,用ROW/COL。我第一次写反,雷全布到辅助格子里,棋盘上一个雷都没有。rand()后面忘了+ 1。 上面说过了。- 布雷没判重。 也说过,但值得再说一遍,因为比较阴。
- cls清屏吃掉提示。 上面也说过。
- 一开始防呆忘做齐了。 让别人玩出来bug了,后续选择难度与坐标的时候要是输入字母就会卡死死循环。(在这里再次感谢一下黑暗的丹 喵)

我的问题喵,本来想多个功能,没想到多了个bug喵。。。
还没解决,可以改进的:
int count;写在头文件里,其实不是很规范喵。 两个.c都 include 了这个头文件(太懒了喵),我现在 VS 的配置能编译,但正经写法应该是头文件里写extern int count;,再在某个.c里定义一次。- 没有插旗与递归炸开空白,其实这是我最想后续改进的(已经在学了喵),扫雷最爽的就是点一下可以炸开没雷的空白区域嘞。
- 没有首击保护。 第一下就可能踩雷,因为还没做。真正的扫雷会保证你第一次点一定安全,后续也将严肃改进。
九、代码放在 GitHub 上了,可以拉下来玩玩
完整代码我 push 了:
用 Visual Studio 打开 saolei.slnx,编译运行就行。三个文件都在 saolei/ 目录下:
css
saolei/
├─ game.h 常量和函数声明
├─ saolei.c 菜单、难度选择、main
└─ dingyi.c 初始化、打印、布雷、排雷
只想看核心逻辑的话直接翻 dingyi.c,棋盘相关的函数都在那儿。README 我也写了,照着跑就行。
可以来撒泼我一下喵。 Easy 难度 71 格其实没那么难翻完,hard 那 20 颗雷就有点刺激了(棋盘不变)。踩雷了别骂我,雷是随机的,不关我事啊喵。

很快就踩雷了喵...
十、最后
做完最大的感受是:二维数组、循环、分支,单独学的时候就只是一堆零件。 你能背出语法,能做对课后题,但你不知道它们该怎么用。
等把它们接成一个能跑能玩能输能赢的东西,那些代码块才有了位置。上一篇学的 switch、do-while、嵌套 for、break、continue,还有那个我说"别用"结果自己用了的 goto(打脸了喵) ,这次全上场了。
下一步想加的是空白自动展开------点开一片空白自动铺开那种,扫雷最爽的机制。不过那个要用递归,学完回来补 (下一篇吧喵) ,到时候再写一篇 "给我的扫雷动手术"的大型DLC哈哈哈哈哈。
如果你也刚学完二维数组和循环,真的,动手写一个小游戏吧。那种,刷再多选择题都换不来喵。
最后还是哪里写得不对大佬们可以来评论区骂我喵。
参考与来源
-
代码 :来自自己的练习,已开源在 github.com/S2MPLE273/s...。
-
清屏思路 :感谢 CSDN 博主「黑暗的丹」的《从"一碰就碎"到"铜墙铁壁":大一新生的 C 语言扫雷实战复盘》,我参考了里面
system("cls")的做法。该文还有递归展开、等进阶内容喵,推荐大家阅读。 -
系列前作 :C 语言分支与循环笔记(掘金)