C语言学习之项目练习:扫雷游戏
C语言学习至今,今天我们来完成C语言的一个小项目:扫雷游戏。
扫雷游戏是经典的网页小游戏,想要完成它我们需要几个步骤
扫雷游戏的预设效果
如图所示,扫雷具有多个版本,这里作为演示,只演示最简单的10个雷的版本。

扫雷游戏的玩法思路如下:
1找一个位置排雷,如果它是雷就被炸死,如果不是雷,统计它周围的雷的个数。
2如果找全所有的雷的个数,则游戏通关
听着是不是很简单?
但是实施起来的话,也是稍微有些许难度的。
在本项目中,我们需要多个文件来运行该项目,它们分别为:
(1)test.c 主要用于项目的思路的实现
(2)game.h 用于定于宏和函数的声明
(3)game.c 用于具体的实现游戏的函数的定义
扫雷游戏的实现
扫雷游戏的代码骨架
首先,我们需要打印菜单。预设的函数和效果如下


接着,我们进入了游戏。在游戏中,我们要选择是否进入游戏,因此要运用switch语句来实现。如下图中的代码所示,当我们选择1时开始游戏,选择0时退出游戏,而选择其他数字时会进入default语句提示错误让你继续进行选择。在这里你输入字符而不是数字时,会陷入死循环。PS:作者实测

这里之所以要用do……while语句,是因为无论循环变量是否符合条件,我们都要进入循环完成一次游戏的选择。
srand的调用是为了后续随机布置雷的调用的。
接下来核心的部分就是该项目的核心部分:game函数游戏实现。
扫雷游戏的函数实现
游戏函数的代码实现如下:(在布置雷后将雷的信息打印出来是排查代码时候方便所致,如果为了游玩,建议删除或者注释该行代码)
头文件引用与宏定义与函数的声明
该项目中game.h入图二所示

在这里我们可以进行函数的声明,声明就是:告诉编译器函数的名称、返回值类型和参数类型,但不提供具体实现。(在实际应用中函数的定义并不总是希望别人知道)
初始化棋盘
首先我们需要两个棋盘,即两个二维数组分别初始化,预期效果如下:(因为在这一阶段下我们还没有学习用户界面设计,所以只能用命令行来实现游戏)

其中1棋盘负责打印初始棋盘,2棋盘负责显示和布置雷。
在这里可能会有一个问题:ROWS和COLS的作用是什么呢?
实际上,我们玩该项目的扫雷的时候只有9x9的棋盘,但是玩数组的时候我们要对所在左边周围一个的范围进行搜查,这样就会涉及到数组越界的问题,所以在该项目中,我们选择用更大的数组范围(即更高的内存)换更便利的写作方式。
具体实现概念如下:图中黄色的部分是我们游玩的棋盘,而黄色和绿色加在一起的部分才是我们初始化期盼的全部内容。

初始化棋盘的代码如下所示

打印棋盘
打印棋盘主要是打印棋盘最开始的样子,即如下图所示的样子

打印棋盘的代码并不难实现,但是只有棋盘难以确定位置,所以我们需要打印行列号

注意:打印列号时候如果i从1开始会导致行号的错位,即如下情况

布置雷
在基础模式中,我们需要打印随机布置10个雷,并把他们布置在合法的位置
图中所示的easycount是一个在game.h中新定义的宏变量,它表示在简单情况下雷个数量

在布置雷的时候我们需要使用rand函数来随机布置雷的信息。
初始化x和y之所以要加1是因为x和y表示的是实际的坐标而不是数组的下标,数组的下标相较于实际的坐标x和y均要-1。
如图所示,我们布置雷的时候不允许雷越界。但是rand函数的变化范围非常的大,从0到2147483647(即 2^31 - 1)(常见的编译器,如gcc等)或者32767(即 2^15 - 1)(老版本编译器或者嵌入式系统),因此我们需要对其进行约束
rand函数的使用案例(选择性观看)
3. 生成特定范围的随机数
若需生成 [a, b] 之间的随机数,需对 rand() 的结果进行缩放:
公式:
int num = a + rand() % (b - a + 1);
示例:生成 [1, 10] 的随机数
int num = 1 + rand() % 10;
注意:
-
取模运算的偏差:当
RAND_MAX不是(b - a + 1)的整数倍时,结果可能分布不均。 -
更精确的方法(减少偏差):
int range = b - a + 1; int num = a + (int)((double)rand() / (RAND_MAX + 1.0) * range);
-
伪随机性与种子初始化
-
rand()是伪随机数生成器,依赖种子值(默认种子为1)。 -
使用
srand()初始化种子以增强随机性(通常用当前时间): -
#include <time.h>
srand(time(0)); // 设置种子为当前时间
代码示范案例
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
int main() {
srand(time(0)); // 初始化种子
for (int i = 0; i < 5; i++) {
int num = rand() % 100; // 生成0~99的随机数
printf("%d ", num);
}
return 0;
}
总结
| 特性 | 说明 |
|---|---|
| 默认范围 | 0 到 RAND_MAX(含) |
典型 RAND_MAX | 2147483647(现代系统)或 32767(旧系统) |
| 种子依赖 | 需用 srand() 初始化种子,否则每次运行生成相同的随机数序列 |
| 均匀性保证 | 直接取模可能引入偏差,建议使用浮点数缩放方法 |
检查雷并开始玩游戏
在布置好雷后,我们要做的就是开始排查雷了。
首先我们要初始化坐标量以及剩余次数。

接下来我们要判断下坐标是否合法,因此便有了如下if语句的内容

当坐标不合法时候,就会提示让你重新输入
当棋盘2上的值为‘1’时,就会被炸死。如果没有被炸死的话,就会统计周围雷的个数并继续排查雷(排查雷的个数是另外的一个函数,它的原理如下图所示)
该图中,所有的坐标均为字符而不是数字,但是仍然可以计算,因为数字的ASCII码的数值是连续的,‘0’的ASCII值为48,‘1’的ASCII值为49,‘1’-‘0’=1仍然成立,因此仍然可以计算周围有几个雷。

具体实现如下:(因为其计算过程比较繁琐因此需要函数的调用)

当我们排除掉所有的雷的时候,游戏结束。
感谢各位读者大大看到这里,感谢各位,在这里作者大大求一个小小的赞谢谢。
宏变量和一般变量(PS:之后的内容选择性观看)
在C语言中,宏变量(通过#define定义)和普通变量(通过数据类型声明)有本质区别。以下是两者的对比分析:
1. 定义方式
| 宏变量 | 普通变量 |
|---|---|
通过预处理器指令#define定义 | 通过数据类型声明(如int, float) |
示例:#define PI 3.1415 | 示例:const float PI = 3.1415f; |
2. 处理阶段
| 宏变量 | 普通变量 |
|---|---|
| 在预处理阶段被替换为字面量 | 在编译阶段分配内存地址 |
| 编译器看到的代码是替换后的文本 | 编译器处理实际的变量操作 |
3. 内存与类型
| 宏变量 | 普通变量 |
|---|---|
| 无内存占用(纯文本替换) | 占用内存空间(栈/堆) |
| 无类型检查(直接替换) | 有严格的类型检查 |
示例问题:#define VALUE "123"int x = VALUE;(编译报错) | 正确写法:const char* VALUE = "123"; |
4. 作用域与生命周期
| 宏变量 | 普通变量 |
|---|---|
| 从定义处到文件结束有效(无作用域) | 遵循作用域规则(局部/全局) |
可用#undef取消定义 | 生命周期由存储类型决定(自动/静态) |
5. 调试与错误
| 宏变量 | 普通变量 |
|---|---|
| 调试时不可见(替换后的代码) | 调试时可观察内存值和类型 |
| 错误可能难以追踪(如重复替换副作用) | 错误通常有明确提示 |
6. 典型应用场景
| 宏变量 | 普通变量 |
|---|---|
定义常量(如数组大小)#define MAX_SIZE 100 | 存储运行时数据int count = 0; |
条件编译标志#define DEBUG_MODE | 配置参数(需运行时修改)int debug_mode = 1; |
简化复杂表达式#define SQUARE(x) ((x)*(x)) | 存储中间计算结果 |
7. 代码示例对比
宏变量的潜在问题:
#include <stdio.h>
#define SQUARE(x) x*x // 有缺陷的宏
int main() {
int a = 5;
printf("%d\n", SQUARE(a+1)); // 展开为 5+1*5+1 = 11(非预期)
return 0;
}
修正方法:
#define SQUARE(x) ((x)*(x))
普通变量的安全实现:
#include <stdio.h>
int square(int x) {
return x * x;
}
int main() {
int a = 5;
printf("%d\n", square(a+1)); // 正确输出36
return 0;
}
总结:如何选择?
-
使用宏:
-
需要跨文件共享常量
-
需要条件编译控制
-
简单代码片段复用(需谨慎)
-
-
使用变量:
-
需要存储运行时数据
-
需要类型安全和调试支持
-
需要作用域控制
-
实际开发中应优先使用const常量或函数,仅在必要时使用宏。
函数的定义声明调用(上一篇博客遗漏的内容)
1.函数定义(Function Definition)
作用:提供函数的具体实现,包括函数体。
返回值类型 函数名(参数列表) {
// 函数体
}
特点:
-
每个函数只能定义一次(否则链接错误)。
-
定义必须与声明一致(返回值、参数类型)。
-
若未定义但被调用,链接器报错(
undefined reference)。
2. 函数声明(Function Declaration)
作用:告诉编译器函数的名称、返回值类型和参数类型,但不提供具体实现。
语法:返回值类型 函数名(参数类型列表);
示例
int add(int a, int b); // 声明一个返回int、接受两个int参数的函数
特点:
可多次声明,通常放在头文件(.h)或代码文件开头。
参数名可省略(但类型必须保留):int add(int, int);
若未声明直接调用,编译器可能发出警告(取决于标准)。
3. 函数调用(Function Call)
作用:在代码中执行函数。
语法:函数名(实际参数)
示例:int result = add(3, 5); // 调用add函数,传入3和5
特点:
总结
-
参数类型和数量必须与声明/定义一致。
-
可嵌套调用(如
printf("%d", add(2,3)))。 -
三者的区别对比
特性 声明 定义 调用 目的 告知编译器函数的存在 提供函数的具体实现 执行函数功能 语法 以分号 ;结尾包含函数体 {}函数名加参数 ()次数 可多次声明 只能定义一次 可多次调用 位置 头文件或代码文件开头 代码文件中 函数可访问的任何位置 必需性 调用前需声明(除非定义在前) 必须定义才能使用 无 -
常见错误及解决
-
未声明直接调用(定义在调用之后):
错误:warning: implicit declaration of function 'add'
解决:在调用前添加函数声明。 -
重复定义:
错误:redefinition of 'add'
解决:确保每个函数只定义一次。 -
参数类型不匹配:
错误:conflicting types for 'add'
解决:保持声明、定义、调用的参数类型一致。 -
声明:是函数的“身份证”,告诉编译器如何检查调用。
-
定义:是函数的“身体”,提供具体功能实现。
-
调用:是函数的“使用”,触发函数执行。
-
最佳实践:始终先声明函数(或通过头文件),再定义和调用,避免隐式声明导致的问题。
更多推荐
所有评论(0)