C语言扫雷游戏项目实战:从零实现控制台版扫雷
2026/8/9 2:11:36 网站建设 项目流程

这次我们来看一个C语言必做项目:扫雷游戏。对于大一计算机专业的学生,或者任何想巩固C语言基础、理解程序逻辑和游戏开发流程的初学者来说,这个项目都是一个绝佳的练手选择。它不依赖复杂的图形库,核心在于二维数组、随机数、递归算法和基本的控制台交互,能让你把C语言的语法知识串联起来,形成一个完整的、可运行的程序。

本文的目标很直接:带你从零开始,一步步用C语言实现一个控制台版的扫雷游戏。我们会提供完整的源码和逐行讲解,重点不是让你复制粘贴,而是理解每一行代码背后的逻辑——为什么这么设计数组?递归展开怎么实现?游戏状态如何判断?通过这个项目,你将巩固指针、数组、函数、循环、条件判断等核心语法,并初步接触模块化编程和游戏状态机的思想。

下面,我们就直接进入正题,从游戏规则分析开始,到代码模块拆分,最后完成一个功能完整的扫雷程序。

1. 核心能力速览

在动手编码前,我们先明确这个“项目”能做什么,以及你需要掌握哪些前置知识。

能力项说明
项目类型控制台(命令行)小游戏
技术栈纯C语言,标准库(stdio.h, stdlib.h, time.h等)
核心知识点二维数组、随机数生成、递归算法、函数封装、循环与条件控制
开发环境任何支持C语言的IDE或编译器(如VS Code, Dev-C++, Code::Blocks, gcc)
硬件门槛无特殊要求,普通电脑即可
输出形式在命令行窗口中显示雷区,通过键盘输入坐标进行游戏
适合场景C语言初学者巩固语法、理解程序结构、完成课程设计或大作业
项目收获理解游戏逻辑实现、模块化编程思想、调试技巧

这个项目的重点不在于华丽的界面,而在于逻辑的严谨性。一个9x9的棋盘,埋着10颗雷,你需要用代码精确地模拟扫雷的所有规则。

2. 适用场景与使用边界

适合谁?

  • 计算机专业大一学生:正在学习C语言,需要将分散的语法点(如数组、函数)应用于一个综合项目。
  • 编程自学者:想通过一个有趣的小项目检验和提升自己的C语言实战能力。
  • 需要课程设计/大作业灵感的同学:这是一个结构清晰、难度适中、代码量合适的选题。

能解决什么问题?

  1. 语法串联:将变量、循环、分支、数组、函数等孤立的知识点,在一个具体项目中融会贯通。
  2. 逻辑思维训练:如何用程序语言描述游戏规则(如翻开格子、计算周围雷数、递归展开空白区域)。
  3. 调试能力提升:游戏逻辑复杂,出现BUG时(如数组越界、死递归)需要定位和修复,这是宝贵的调试经验。
  4. 模块化设计初体验:学习将不同功能(初始化、打印、玩家操作、判断胜负)封装成独立的函数。

不适合什么场景?

  • 希望学习图形界面(GUI)编程,如使用EasyX、SDL等库。
  • 希望开发商业级或网络对战的扫雷游戏。
  • 对C语言基础语法(如数组、指针)尚不熟悉,建议先夯实基础再挑战本项目。

安全与合规边界

本项目为纯数学逻辑与算法练习,不涉及任何网络通信、用户数据收集或外部资源调用,无安全风险。代码完全开源,可用于学习、修改和分享。

3. 环境准备与前置条件

开始编码前,请确保你的开发环境就绪。

3.1 编译器与IDE

你需要一个C语言编译器。任选其一即可:

  • Windows:
    • Visual Studio (推荐):安装时选择“使用C++的桌面开发”,它包含了强大的C/C++编译器MSVC和调试器。
    • Code::Blocks:轻量级开源IDE,自带MinGW编译器。
    • Dev-C++:经典的教学用IDE,集成MinGW。
    • MinGW-w64+VS Code:更灵活的配置方案,适合喜欢折腾的用户。
  • Linux/macOS:
    • 系统通常自带gcc编译器。在终端输入gcc --version确认。

3.2 基础知识储备

确保你理解以下C语言概念,如果不熟,请先回顾:

  • 基本数据类型(int,char
  • 二维数组的定义与访问
  • 函数的定义、声明与调用
  • 指针的基本概念(至少理解数组名作为参数传递)
  • 流程控制(if-else,while,for
  • 标准输入输出(printf,scanf
  • 随机数生成(srand,rand
  • 递归函数(用于实现空白区域展开)

3.3 项目目录结构(建议)

创建一个清晰的目录来管理你的项目文件。

mine_sweeper/ ├── mine_sweeper.c // 主源文件,包含所有代码(初学建议) ├── mine_sweeper.h // (可选)头文件,用于函数声明 ├── README.md // (可选)项目说明 └── .gitignore // (可选)版本控制忽略文件

对于初学者,将所有代码写在一个.c文件中是完全可行的,便于管理和编译。

4. 游戏逻辑分析与设计

在写代码之前,我们必须把扫雷的游戏规则“翻译”成程序逻辑。这是最关键的一步。

4.1 数据表示:我们需要几个棋盘?

这是核心设计。一个棋盘是不够的。我们需要用两个9x9的二维数组(char类型)来表示不同的信息:

  1. mine[][] - 雷的布局棋盘
    • 作用:存储地雷的真实位置。这个棋盘对玩家不可见。
    • 元素值:‘1’表示有雷,‘0’表示无雷。
    • 为什么用字符‘1’‘0’?方便后续计算周围雷数时直接相加(ASCII码相减或转换)。
  2. show[][] - 显示给玩家的棋盘
    • 作用:存储玩家当前看到的界面信息。
    • 元素值:
      • ‘*’:未翻开状态。
      • ‘0’-‘8’:已翻开,数字表示周围8格中的雷数。
      • ‘ ’(空格):已翻开,且周围无雷(即数字0,我们显示为空格更美观)。

4.2 核心游戏流程(状态机)

  1. 初始化
    • 初始化mine数组全为‘0’
    • 随机生成10个不重复的位置,将其在mine数组中设为‘1’
    • 初始化show数组全为‘*’
  2. 游戏主循环
    • 打印show棋盘。
    • 玩家输入坐标(x, y)
    • 判断坐标合法性。
    • 检查该位置在mine中是否为雷(‘1’):
      • 是雷 -> 游戏结束,玩家失败。揭示所有雷的位置。
      • 不是雷 -> 计算该位置周围8格的雷数(count)。
        • 如果count > 0:将show[x][y]设为相应的数字字符(如count + ‘0’)。
        • 如果count == 0递归展开。将该位置show[x][y]设为空格,并递归地翻开其周围8个格子(若其未被翻开且不是雷)。
    • 检查游戏是否胜利:统计show中未翻开的格子(‘*’)数量是否等于总雷数(10)。若是,则玩家胜利。
  3. 游戏结束:显示结果,并询问是否再来一局。

4.3 关键算法:递归展开

这是扫雷游戏的灵魂。当玩家点开一个周围无雷(count==0)的格子时,程序应自动翻开所有与之相连的、周围也无雷的格子,直到被数字格子包围。

// 伪代码描述递归展开函数 ExpandBlank void ExpandBlank(int x, int y, char mine[ROWS][COLS], char show[ROWS][COLS]) { // 1. 边界检查:坐标是否在有效范围内 // 2. 终止条件:如果当前位置已经处理过(不是‘*’),则返回 if (show[x][y] != ‘*‘) return; // 3. 计算当前格子周围雷数 count int count = GetMineCount(mine, x, y); // 4. 根据雷数更新 show 棋盘 if (count > 0) { show[x][y] = count + ‘0‘; // 转为字符数字 } else { show[x][y] = ‘ ‘; // 周围无雷,显示空格 // 5. 递归调用:向周围8个方向展开 // 注意:递归前要确保新坐标有效,避免无限递归和栈溢出 ExpandBlank(x-1, y-1, mine, show); ExpandBlank(x-1, y, mine, show); ExpandBlank(x-1, y+1, mine, show); ExpandBlank(x, y-1, mine, show); ExpandBlank(x, y+1, mine, show); ExpandBlank(x+1, y-1, mine, show); ExpandBlank(x+1, y, mine, show); ExpandBlank(x+1, y+1, mine, show); } }

注意:实际编码时,必须严格检查递归调用的坐标是否在棋盘有效范围内,否则会导致数组越界访问,程序崩溃。

5. 代码实现:分步构建扫雷游戏

我们将按照模块化的思想,一步步实现所有函数。为了便于理解棋盘边界处理,我们采用一个常用技巧:创建比实际显示区域大一圈的数组

例如,显示给玩家的是9x9,但我们创建11x11的数组。最外面一圈始终不放雷,并且不显示给玩家。这样做的好处是:在计算任意一个有效格子周围的雷数时,其周围的8个坐标永远在数组范围内,无需进行繁琐的边界判断,简化了代码逻辑。

5.1 定义与头文件

// mine_sweeper.c #include <stdio.h> #include <stdlib.h> #include <time.h> // 定义常量 #define ROW 9 // 显示的行数 #define COL 9 // 显示的列数 #define ROWS (ROW+2) // 实际数组的行数(包含边界) #define COLS (COL+2) // 实际数组的列数(包含边界) #define MINE_COUNT 10 // 雷的总数

使用常量便于后期修改游戏难度(如改为16x16,40颗雷)。

5.2 函数声明

// 初始化棋盘 void InitBoard(char board[ROWS][COLS], int rows, int cols, char set); // 打印棋盘 void DisplayBoard(char board[ROWS][COLS], int row, int col); // 布置雷 void SetMine(char mine[ROWS][COLS], int row, int col); // 统计(x,y)坐标周围雷的个数 int GetMineCount(char mine[ROWS][COLS], int x, int y); // 递归展开空白区域 void ExpandBlank(char mine[ROWS][COLS], char show[ROWS][COLS], int x, int y); // 玩家扫雷 void FindMine(char mine[ROWS][COLS], char show[ROWS][COLS], int row, int col); // 游戏主逻辑 void game();

5.3 函数实现详解

5.3.1 初始化棋盘InitBoard

将棋盘的每一个元素都设置为指定的字符set

void InitBoard(char board[ROWS][COLS], int rows, int cols, char set) { for (int i = 0; i < rows; i++) { for (int j = 0; j < cols; j++) { board[i][j] = set; } } }
  • set参数:初始化mine棋盘时传入‘0’;初始化show棋盘时传入‘*’
5.3.2 打印棋盘DisplayBoard

打印出9x9的游戏区域,并加上行号和列号,方便玩家输入坐标。

void DisplayBoard(char board[ROWS][COLS], int row, int col) { printf("-------- 扫雷游戏 --------\n"); // 打印列号 printf(" "); for (int i = 1; i <= col; i++) { printf("%d ", i); } printf("\n"); printf(" +"); for (int i = 1; i <= col; i++) { printf("--"); } printf("\n"); // 打印每一行 for (int i = 1; i <= row; i++) { printf("%d | ", i); // 打印行号 for (int j = 1; j <= col; j++) { printf("%c ", board[i][j]); } printf("\n"); } printf("\n"); }

注意:我们只打印从索引1row/col的区域,最外圈的边界不显示。

5.3.3 布置雷SetMine

9x9的有效区域内,随机生成MINE_COUNT个不重复的雷。

void SetMine(char mine[ROWS][COLS], int row, int col) { int count = MINE_COUNT; while (count) { // 生成1-row, 1-col范围内的随机坐标 int x = rand() % row + 1; int y = rand() % col + 1; // 如果该位置没有雷,则布置雷 if (mine[x][y] == ‘0‘) { mine[x][y] = ‘1‘; count--; } } }
  • srand((unsigned int)time(NULL));需要在main函数中调用一次,以确保每次运行游戏雷的位置都不同。
5.3.4 计算周围雷数GetMineCount

计算坐标(x,y)周围8个格子中雷的总数。

int GetMineCount(char mine[ROWS][COLS], int x, int y) { // 因为mine数组中‘1’代表雷,‘0’代表安全,所以将周围8个字符相加后减去8个‘0’的ASCII码,就是雷的数量。 // 更直观的写法是遍历周围8格,判断是否为‘1’。 int count = 0; for (int i = x - 1; i <= x + 1; i++) { for (int j = y - 1; j <= y + 1; j++) { if (mine[i][j] == ‘1‘) { count++; } } } return count; }

由于我们使用了带边界的数组,即使(x,y)在边缘(如第1行),ij的循环也不会越界,因为最外圈是安全区。

5.3.5 递归展开空白区域ExpandBlank

这是游戏的核心趣味点所在。

void ExpandBlank(char mine[ROWS][COLS], char show[ROWS][COLS], int x, int y) { // 递归终止条件1:坐标越界(虽然有大数组保护,但显式检查更安全) if (x < 1 || x > ROW || y < 1 || y > COL) { return; } // 递归终止条件2:该位置已被处理过(不是‘*’) if (show[x][y] != ‘*‘) { return; } int count = GetMineCount(mine, x, y); if (count > 0) { // 周围有雷,显示数字 show[x][y] = count + ‘0‘; // 将数字转换为字符 } else { // 周围无雷,显示空格,并递归展开 show[x][y] = ‘ ‘; // 递归展开周围8个格子 ExpandBlank(mine, show, x - 1, y - 1); ExpandBlank(mine, show, x - 1, y); ExpandBlank(mine, show, x - 1, y + 1); ExpandBlank(mine, show, x, y - 1); ExpandBlank(mine, show, x, y + 1); ExpandBlank(mine, show, x + 1, y - 1); ExpandBlank(mine, show, x + 1, y); ExpandBlank(mine, show, x + 1, y + 1); } }

重要提醒:对于非常大的棋盘(如30x30以上)且空白区域巨大时,深度递归可能导致栈溢出。对于学习项目,9x9的规模完全安全。

5.3.6 玩家扫雷核心逻辑FindMine

这是游戏的主循环,处理玩家输入、判断踩雷、展开、检查胜利。

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 - MINE_COUNT) { DisplayBoard(show, row, col); printf("请输入要排查的坐标 (格式: x y): "); scanf("%d %d", &x, &y); // 判断坐标合法性 if (x >= 1 && x <= row && y >= 1 && y <= col) { if (show[x][y] != ‘*‘) { printf("该位置已被排查,请重新输入!\n"); continue; } if (mine[x][y] == ‘1‘) { // 踩到雷了 printf("很遗憾,你踩到雷了!游戏结束。\n"); DisplayBoard(mine, row, col); // 展示雷的位置 break; } else { // 安全位置 ExpandBlank(mine, show, x, y); // 重新计算已翻开的非雷格子数 win = 0; for (int i = 1; i <= row; i++) { for (int j = 1; j <= col; j++) { if (show[i][j] != ‘*‘ && show[i][j] != ‘F‘) { // ‘F‘可代表标记的旗子,本例未实现 win++; } } } // 也可以直接清屏后打印,体验更好 system("cls"); // Windows系统清屏 // system("clear"); // Linux/macOS系统清屏 } } else { printf("坐标非法,请重新输入!\n"); } } if (win == row * col - MINE_COUNT) { printf("恭喜你,排雷成功!\n"); DisplayBoard(mine, row, col); } }
  • win变量的计算:当所有非雷的格子(row*col - MINE_COUNT)都被翻开时,游戏胜利。
  • system(“cls”)用于清屏,让界面更清爽,但请注意这是平台相关函数。
5.3.7 游戏主函数game

整合所有模块,组织游戏流程。

void game() { // 创建两个棋盘 char mine[ROWS][COLS] = {0}; // 存放雷的信息 char show[ROWS][COLS] = {0}; // 展示给玩家的信息 // 初始化棋盘 InitBoard(mine, ROWS, COLS, ‘0‘); // ‘0‘表示无雷 InitBoard(show, ROWS, COLS, ‘*‘); // ‘*‘表示未翻开 // 布置雷 SetMine(mine, ROW, COL); // 调试用:可以打印雷盘看看 // DisplayBoard(mine, ROW, COL); // 开始扫雷 FindMine(mine, show, ROW, COL); }
5.3.8 主函数main

程序的入口,控制游戏循环。

int main() { int input = 0; // 设置随机数种子 srand((unsigned int)time(NULL)); do { printf("************************\n"); printf("***** 1. play ****\n"); printf("***** 0. exit ****\n"); printf("************************\n"); printf("请选择: "); scanf("%d", &input); switch (input) { case 1: game(); break; case 0: printf("退出游戏\n"); break; default: printf("选择错误,重新选择!\n"); break; } } while (input); return 0; }

6. 编译、运行与效果验证

6.1 编译命令

将上述所有函数代码整合到一个mine_sweeper.c文件中。

  • 使用gcc(Linux/macOS/Windows MinGW):
    gcc mine_sweeper.c -o mine_sweeper
  • 使用Visual Studio:
    • 新建一个空项目,添加源文件,直接点击“本地Windows调试器”运行。

6.2 运行与测试

  1. 启动游戏:运行编译生成的可执行文件(如./mine_sweepermine_sweeper.exe)。
  2. 选择开始:在菜单中输入1开始游戏。
  3. 游戏界面:你会看到一个9x9的棋盘,布满*
  4. 输入坐标:按照提示输入行号和列号(如5 5)。
  5. 观察结果
    • 如果点中雷,游戏结束,显示所有雷的位置。
    • 如果点中安全区,会显示数字或展开一片空白区。
  6. 胜利条件:翻开所有非雷格子(71个)即获胜。

6.3 功能验证清单

  • [ ]初始化:游戏开始时,show棋盘是否全为*
  • [ ]随机布雷:每次重新开始游戏,雷的位置是否不同?
  • [ ]数字显示:点击安全区,是否正确显示周围8格的雷数(1-8)?
  • [ ]递归展开:点击一个周围无雷的格子,是否自动展开一片相连的空白区域?
  • [ ]踩雷判定:点击雷的位置,游戏是否立即结束并显示雷图?
  • [ ]胜利判定:翻开所有71个安全格后,游戏是否提示胜利?
  • [ ]输入校验:输入非法坐标(如0, 10)或已翻开坐标,是否有错误提示并允许重新输入?
  • [ ]重复游戏:一局结束后,返回菜单,选择1是否能开始全新的一局?

7. 代码优化与功能扩展(可选)

完成基础版本后,你可以尝试以下扩展,让游戏更完善、更接近Windows原版扫雷。

7.1 添加标记地雷功能(插旗)

  • show棋盘中,用‘F’表示玩家标记的雷。
  • 修改FindMine函数,接受两种输入模式:1. 翻开 2. 标记/取消标记
  • 标记不算作翻开操作,不影响胜利条件判断。

7.2 添加游戏计时器

  • 使用time.h库中的time_t类型记录游戏开始时间。
  • 在游戏循环中计算并显示已用时间。

7.3 添加难度选择

  • 修改ROW,COL,MINE_COUNT为变量。
  • 在游戏开始前让玩家选择难度(初级9x9-10雷,中级16x16-40雷,高级16x30-99雷)。

7.4 优化递归展开性能

  • 当前的递归在极端情况下(如第一个点就是大片空白)可能栈深度较大。可以考虑使用队列(Queue)配合循环来实现非递归的广度优先搜索(BFS)展开,这对于超大棋盘更稳定。

7.5 图形化界面(进阶)

  • 使用EasyX(Windows)或SDL等图形库,将控制台字符界面替换为真正的格子、图片和鼠标操作。

8. 常见问题与排查方法

在实现过程中,你可能会遇到以下问题:

问题现象可能原因排查方式解决方案
编译错误:undefined reference to ‘WinMain’在Windows下创建项目类型错误(如选了Windows桌面应用)检查项目类型创建“控制台应用”或“空项目”。
运行后闪退main函数执行完毕直接退出;或scanf输入格式不匹配导致异常main函数末尾或scanf后加getchar()暂停;检查scanf格式字符串main函数return前加system(“pause”)getchar();确保scanf(“%d %d”, &x, &y)与输入匹配。
雷的位置每次都一样没有调用srandsrand种子固定(如srand(1)检查main函数开头确保调用了srand((unsigned int)time(NULL)),且只调用一次。
递归展开时程序崩溃(栈溢出)递归函数没有正确的终止条件,导致无限递归检查ExpandBlank函数的终止条件(坐标有效性和状态检查)确保在递归调用前判断坐标是否在1-ROW/COL范围内,以及该位置是否未被处理(show[x][y]==‘*‘)。
计算周围雷数时结果错误GetMineCount函数逻辑错误,或mine数组初始化/布置有误打印出mine棋盘进行调试;单步调试GetMineCount函数确认mine数组中‘1’和‘0’的意义;检查循环边界是否正确。
游戏无法判断胜利win计数逻辑错误在每次翻开格子后,打印win的值进行调试确认胜利条件是win == row*col - MINE_COUNT,且win正确统计了所有非‘*’且非‘F’的格子。
输入坐标后无反应FindMine函数中的while循环条件可能永远为真,或输入处理逻辑有误检查while循环的退出条件;在scanf后打印输入的x,y确保win变量在每次安全操作后都正确更新;检查输入缓冲区内是否有残留字符(可用fflush(stdin)清空,但注意可移植性)。

9. 最佳实践与项目心得

  1. 先画图,再编码:在动手写代码前,在纸上画出两个棋盘,模拟几次游戏过程,理清数据流动和状态变化。这能节省大量调试时间。
  2. 模块化测试:不要一次性写完所有代码。写完一个函数(如SetMine),就立刻写几行测试代码(如打印mine数组),验证其正确性。
  3. 善用调试工具:学会使用IDE的调试器(设置断点、单步执行、查看变量值),这是定位逻辑错误最有效的方法。
  4. 理解“大数组”技巧:使用ROWS/COLS比实际ROW/COL大2的数组,是处理二维数组边界问题的经典技巧,能简化代码逻辑。
  5. 递归要谨慎:理解递归的“递”和“归”。确保有明确的终止条件,避免无限递归导致栈溢出。
  6. 代码风格:保持一致的缩进、合理的变量命名、必要的注释。良好的代码风格能让你的程序更易读、易维护。
  7. 版本管理:即使是一个人做小项目,也建议使用Git。每完成一个主要功能就提交一次,如果新代码引入了BUG,可以轻松回退。

通过这个项目,你收获的不仅仅是一个能运行的扫雷游戏。你更收获了将复杂问题分解为模块、用程序语言描述世界规则、以及系统化调试和测试的能力。这些能力,是通往更高级编程世界的基石。建议你将最终代码保存好,并尝试实现第7节中的扩展功能,这会让你的简历和作品集更加亮眼。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询