从零写一个扫雷:两个数组、一个坑,和三个后来加的功能
开头先说清楚
扫雷这个游戏,玩法人人都懂:格子里藏着雷,点开安全格,把雷全标出来就赢。
但自己写一遍,难点不在"点开",在怎么"知道" 。你得让程序知道:这个格子有没有雷、周围有几颗雷、还有多少格没排、什么时候算赢。
这篇文章讲我是怎么一点点把它做出来的。代码是 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';
}
这行看着吓人,其实就三件事:
- 周围 8 个格子的字符加起来
- 每个格子存的是字符
'0'或'1',它们的 ASCII 是 48 和 49 - 直接加,会得到一堆 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 这种小技巧,背后是"字符就是数字"这件事。
三、报错会喊出来的,是文件;不会喊的,是我想当然的地方。 那个死循环我盯了半天代码,最后是靠监视窗口里的地址对出来的。
完整代码三个文件加起来不到三百行。想自己写的话,建议按这个顺序:先让棋盘能打印,再让雷能埋上,最后才写排雷循环。中间每一步都能跑起来,比一口气写完再调好受得多。