简介:一个基于C++编写的扫雷小游戏项目,主要面向C++初学者、图形化编程爱好者以及需要课程设计参考的高校学生。压缩包内不仅包含完整的C++源码,还附带可直接运行的扫雷程序,以及24张游戏界面与素材图片,同时提供Visual Studio工程配置,方便读者直接打开、编译与调试。整个资源共109个文件,涵盖源码、工程配置、可执行文件以及编译过程中产生的tlog、pdb、obj、ipch等中间文件,压缩包大小18.65MB,目录结构清晰,便于按需提取代码、图片或成品程序。目前已有252人学习下载,对希望掌握C++窗口程序开发、事件响应或游戏逻辑设计的读者而言,这套项目提供了从源码到成品的一站式参考:既能运行游戏体验扫雷玩法,也能通过源码逐行理解棋盘生成、随机布雷、翻格、插旗、胜负判定等核心实现,适合用作入门练习或二次开发基础。 最近整理以前写的练手项目,翻出一个C++写的扫雷小游戏。读了一遍代码之后发现,这个项目虽然小,含金量却一点不低——扫雷大家从小玩到大,规则一句话就能说清:点开格子看数字,数字表示周围8格有几颗雷,数字是0就自动向外展开一片,踩到雷就结束。但真拿起C++从头实现一遍,你会发现二维数组、随机生成、坐标偏移、递归或队列遍历、游戏状态机、异常输入处理这些入门阶段最头疼的东西全被串起来了。
我是用纯标准库实现的,不依赖任何图形框架,最终大概200行左右的代码就能在控制台里稳定运行。对刚学完C++语法、想找个项目验证水平的同学来说,这个项目的性价比非常高;对准备C++面试的八股文复习阶段来说,它也能帮你把vector、随机数引擎、BFS这些概念从“背概念”变成“用概念”。下面的内容我会按设计思路、核心代码、实测踩坑的顺序完整展开,照着敲一遍,你也能在终端里跑出自己的扫雷。
1. 为什么拿扫雷当C++练手项目:先想清楚要练什么
1.1 扫雷这个项目到底考了哪些基本功
很多新手写C++小游戏会选猜数字、贪吃蛇、俄罗斯方块,这些都不错,但扫雷有一个独特的优势:它天然就是一张二维表,操作对象是格子坐标,这迫使你必须熟练掌握二维vector的创建、遍历、访问和修改。C++入门阶段最常见的问题就是“一维数组刚明白,二维数组一用就乱”,扫雷能把这个问题一次性治住。
除了二维数据结构,布雷过程涉及到随机数的正确使用——严格来说,C++11之后的随机数生成已经不应该再依赖rand() % n这种老写法;翻开空格子时会出现一片区域连锁展开,这是典型的BFS或DFS应用场景;插旗、判断胜负、首次点击保护这些规则,又要求你把游戏状态管理得清清楚楚。仔细想想,这几乎就是一套迷你版的项目架构。面试里如果被问到“你怎么理解队列”或者“你项目里的状态是怎么管理的”,把扫雷的这段经历讲清楚,比背十道八股文都更有说服力。
1.2 动手前我定下的几个实现方案
为了避免写着写着跑偏,我一开始就给自己列了几条约束:
- 只用C++标准库,不引入任何第三方图形库,保证任何一台装了编译器的机器都能编译运行;
- 界面采用控制台字符渲染,棋盘用字符拼出来,不追求好看,追求逻辑清晰;
- 游戏支持翻开、插旗、退出三个基本操作,开局第一次点击保证不是雷;
- 代码尽量模块化,棋盘生成、数字统计、翻开逻辑、胜负判断分开写,方便后续改成图形界面版。
这几条看起来简单,但它决定了整个项目的走向。特别是“只用标准库”这一条,能逼着你用C++自身的东西把问题解决,而不是遇到界面问题就去搜EasyX,遇到随机问题就去抄老代码。后面所有的实现,都是在这些约束下展开的。
2. 棋盘与布雷:数据结构选对了,后面写代码才顺
2.1 用结构体管理每个格子的状态
扫雷的每一个格子,需要同时记录两件事:一个是格子本身的内容(是雷还是数字,数字是几),另一个是格子的显示状态(未翻开、已翻开、插旗)。刚开始写的时候我图省事,用了一个二维int数组存数字、一个二维bool数组存是否翻开、又一个二维bool数组存是否插旗。结果写着写着就发现,三个数组要同步维护,任何一个下标写错都很难查,代码可读性也差。
后来我改成定义一个Cell结构体:
struct Cell { int number = 0; // -1 表示地雷,0~8 表示周围地雷数 bool revealed = false; // 是否已翻开 bool flagged = false; // 是否已插旗 };棋盘就是vector<vector<Cell>> board,用board.assign(rows, vector<Cell>(cols))一次性初始化。这样每个格子的三个信息被封装在一起,操作起来一目了然:修改number不会碰错revealed,判断显示状态也不会和数字混淆。这是我在这个项目里体会最深的一点——数据结构的组织方式直接决定了后续代码的心情和调试成本。
2.2 布雷:把 rand() 换成 random_device + shuffle
布雷的常规思路是:遍历所有格子,用随机数决定某个格子是不是雷。但这里有两个问题。第一,老式rand() % n产生的随机数质量一般,而且不同平台的表现差异很大;第二,如果Board很大、雷数很少,遍历每个格子再按概率布雷,不容易精确控制总雷数。
更好的做法是把所有格子的编号放进一个容器,然后用std::shuffle打乱,取前mineCount个作为雷。这样做既保证了每个格子被选中的概率完全均等,又精确满足了“总共10颗雷”的硬性要求。代码是这样的:
void Minesweeper::placeMines(int ex, int ey) { vector<int> cells; for (int i = 0; i < rows * cols; i++) { int tx = i / cols; int ty = i % cols; if (tx == ex && ty == ey) continue; // 首次点击的位置不布雷 cells.push_back(i); } mt19937 rng(random_device{}()); shuffle(cells.begin(), cells.end(), rng); for (int i = 0; i < mineCount; i++) { int idx = cells[i]; board[idx / cols][idx % cols].number = -1; } }这里用mt19937(梅森旋转算法)替代了老掉牙的rand(),用random_device取真随机种子。这些在C++11之后都是标准库自带的能力,不需要任何外部依赖。我在实测中发现,如果改成random_device{}()不可用或者太慢的旧编译器,退一步用time(nullptr)做种子也能接受,但本地开发基本不用担心。
2.3 数字统计:八方向偏移数组
地雷布完之后,需要给每个非雷格子计算周围8格的雷数。很多新手会写8个if判断:左边、右边、上边、下边、左上……,代码又长又容易漏。正确做法是定义一个八方向偏移数组:
const int dx[8] = {-1, -1, -1, 0, 0, 1, 1, 1}; const int dy[8] = {-1, 0, 1, -1, 1, -1, 0, 1};然后一个双重循环就搞定:
void Minesweeper::calcNumbers() { for (int x = 0; x < rows; x++) { for (int y = 0; y < cols; y++) { if (board[x][y].number == -1) continue; int cnt = 0; for (int k = 0; k < 8; k++) { int nx = x + dx[k]; int ny = y + dy[k]; if (nx >= 0 && nx < rows && ny >= 0 && ny < cols && board[nx][ny].number == -1) { cnt++; } } board[x][y].number = cnt; } } }这个偏移数组的写法特别典型,后面做BFS展开时同样要用它。习惯之后,处理二维网格问题基本就是这套模板——把逻辑写成对“邻居集合”的遍历,而不是对“8个方向”的重复粘贴。面试里遇到迷宫、岛屿数量、扫雷这类题,这个思路能直接复用。
3. 翻格子、插旗子与胜负判定:核心交互逻辑
3.1 空格区域的连锁翻开:为什么选 BFS 而不是递归
扫雷最核心的交互就是翻开格子。如果翻开的格子数字是0,说明周围8格没有雷,这些邻居也应该自动翻开;邻居里如果有数字0,它的邻居又要继续翻开。这个连锁过程,天然对应图的遍历。
递归写起来非常短:
void reveal(int x, int y) { if (越界 || 已翻开 || 已插旗) return; board[x][y].revealed = true; if (board[x][y].number == 0) { for (8个邻居) reveal(nx, ny); } }但递归有个隐患:当棋盘很大、连续空白区域很大时,递归深度可能变得很深,在栈空间受限的环境下有爆栈风险。我在这个项目里用C++标准库自带的queue实现了一个BFS版本。BFS的好处是显式维护一个待处理队列,不依赖系统调用栈,深度再大也只会占用堆内存:
void Minesweeper::revealEmpty(int sx, int sy) { queue<pair<int, int>> q; q.push({sx, sy}); while (!q.empty()) { auto [x, y] = q.front(); q.pop(); if (x < 0 || x >= rows || y < 0 || y >= cols) continue; if (board[x][y].revealed || board[x][y].flagged) continue; if (board[x][y].number == -1) continue; board[x][y].revealed = true; safeCellsLeft--; if (board[x][y].number == 0) { for (int k = 0; k < 8; k++) { int nx = x + dx[k]; int ny = y + dy[k]; if (nx >= 0 && nx < rows && ny >= 0 && ny < cols) { q.push({nx, ny}); } } } } }这里dx和dy就是前面数字统计用的同一个偏移数组。写完这个BFS,我对队列的理解比看十遍书都透彻——它就是一个“待办清单”,从一个格子出发,把该处理的邻居挨个放进去,处理完一个再取下一个,直到清单为空。
3.2 插旗与操作边界校验
插旗的作用是标记你怀疑是雷的格子,防止手滑点开。对应操作是:如果格子未翻开且未插旗,就插上旗;如果已经插旗,再次执行操作就取消旗。要特别注意,已经翻开的格子不能再插旗,这是边界校验的底线。
这里补充一个我踩过的细节:玩家输入坐标后,程序必须判断坐标是否在棋盘范围内。如果玩家输入o 99 99,直接访问board[99][99]会导致程序崩溃。所以每个操作入口我都会做一次越界检查,非法输入直接忽略并重新进入循环,不用打断游戏流程。这是控制台交互类小游戏最容易忽略、却最关键的一层防守。
3.3 胜利判定与首点踩雷保护
胜利条件不是“插旗插对了多少”,而是“所有非雷格子都被翻开”。我用一个safeCellsLeft成员变量记录剩余未翻开的非雷格子数量,初始值设为rows * cols - mineCount。每成功翻开一个非雷格子就减一,减到0就说明所有安全格都已经被看见了,玩家获胜。
至于首点踩雷,我采用的方法是:首次输入翻开命令时,先把玩家点击的坐标排除在布雷范围之外,再生成雷区。这样无论点哪里,第一脚都是安全的。很多人觉得这是“作弊”,但其实这是扫雷这个游戏的通行设计,它保证了游戏体验的流畅,不然一开局就踩雷确实太挫败了。
4. 终端渲染:用记号在控制台画出棋盘
4.1 棋盘符号约定与打印布局
控制台没有图形界面,所有信息只能靠字符。我的打印规则是这样的:
- 未翻开且未插旗:显示
#; - 已插旗:显示
F; - 已翻开且数字为0:显示空格;
- 已翻开且数字是1~8:显示对应数字;
- 已翻开且是地雷:显示
*。
棋盘上方和左侧分别打印列号和行号,方便玩家输入坐标。坐标从1开始计数,和数组下标从0开始相差1,玩家输入后记得减掉。这部分代码看起来琐碎,但其实是在模拟图形界面的渲染管线——每次操作完都要完整刷新一遍棋盘,让玩家看到最新状态。
4.2 刷新策略与显示优化空间
控制台游戏最常见的刷新方式是每步操作后调用system("clear")或system("cls")清空屏幕,再重新打印棋盘。但这个函数在不同平台上名称不一样——Windows是cls,Linux/macOS是clear。为了保持代码的跨平台可移植性,我在这个版本里干脆不做清屏,把每次刷新后的棋盘直接追加打印在下方,用空行分隔。
缺点是不够美观,但优点是完全可移植。我在后面的扩展建议里会说怎么用条件宏分别处理不同平台。这里我想说的是,做练手项目不要太纠结界面美观,先把逻辑跑通,渲染方案后期可以随时替换。
5. 完整代码:200 行跑通一个可玩的扫雷
5.1 代码结构与关键点说明
整个游戏我封装成一个Minesweeper类,类内维护棋盘、行数列数、雷数、剩余安全格数量。对外只暴露run()作为主循环入口。这样做的好处是状态集中管理,不会到处都是全局变量。主函数只需要创建对象并调用run():
int main() { ios::sync_with_stdio(false); cin.tie(nullptr); Minesweeper game(9, 9, 10); game.showGameHelp(); game.run(); return 0; }第一行ios::sync_with_stdio(false)是C++里常见的cin提速配置,把C++流和C标准IO的同步关掉,输入输出会快一些。对于这个小游戏来说影响不大,但养成习惯没坏处,很多C++教程和面试八股文都会提到这个细节。
5.2 完整源码(可直接编译运行)
下面这段代码就是我的完整实现,直接复制到你的源文件里,用g++或任何支持C++17的编译器编译即可运行:
#include <iostream> #include <vector> #include <queue> #include <random> #include <algorithm> #include <iomanip> #include <utility> using namespace std; struct Cell { int number = 0; bool revealed = false; bool flagged = false; }; class Minesweeper { private: int rows, cols, mineCount; vector<vector<Cell>> board; bool firstClick = true; int safeCellsLeft; const int dx[8] = {-1, -1, -1, 0, 0, 1, 1, 1}; const int dy[8] = {-1, 0, 1, -1, 1, -1, 0, 1}; public: Minesweeper(int r, int c, int m) : rows(r), cols(c), mineCount(m) { board.assign(rows, vector<Cell>(cols)); safeCellsLeft = rows * cols - mineCount; } void showGameHelp() { cout << "==== 扫雷小游戏(C++ 控制台版) ====\n"; cout << "棋盘大小: " << rows << " x " << cols << ",地雷数量: " << mineCount << "\n"; cout << "命令格式: o x y 表示翻开第x行第y列\n"; cout << " f x y 表示在第x行第y列插旗/取消插旗\n"; cout << " q 表示退出游戏\n"; cout << "坐标从1开始,例如翻开第1行第1格输入: o 1 1\n\n"; } void displayBoard() { cout << " "; for (int j = 0; j < cols; j++) { cout << setw(2) << j + 1; } cout << "\n"; for (int i = 0; i < rows; i++) { cout << setw(2) << i + 1; for (int j = 0; j < cols; j++) { const Cell& c = board[i][j]; if (!c.revealed && !c.flagged) { cout << " #"; } else if (!c.revealed && c.flagged) { cout << " F"; } else if (c.number == -1) { cout << " *"; } else if (c.number == 0) { cout << " "; } else { cout << " " << c.number; } } cout << "\n"; } cout << "\n"; } void placeMines(int ex, int ey) { vector<int> cells; for (int i = 0; i < rows * cols; i++) { int tx = i / cols; int ty = i % cols; if (tx == ex && ty == ey) continue; cells.push_back(i); } mt19937 rng(random_device{}()); shuffle(cells.begin(), cells.end(), rng); for (int i = 0; i < mineCount; i++) { int idx = cells[i]; board[idx / cols][idx % cols].number = -1; } } void calcNumbers() { for (int x = 0; x < rows; x++) { for (int y = 0; y < cols; y++) { if (board[x][y].number == -1) continue; int cnt = 0; for (int k = 0; k < 8; k++) { int nx = x + dx[k]; int ny = y + dy[k]; if (nx >= 0 && nx < rows && ny >= 0 && ny < cols && board[nx][ny].number == -1) { cnt++; } } board[x][y].number = cnt; } } } void revealEmpty(int sx, int sy) { queue<pair<int, int>> q; q.push({sx, sy}); while (!q.empty()) { auto [x, y] = q.front(); q.pop(); if (x < 0 || x >= rows || y < 0 || y >= cols) continue; if (board[x][y].revealed || board[x][y].flagged) continue; if (board[x][y].number == -1) continue; board[x][y].revealed = true; safeCellsLeft--; if (board[x][y].number == 0) { for (int k = 0; k < 8; k++) { int nx = x + dx[k]; int ny = y + dy[k]; if (nx >= 0 && nx < rows && ny >= 0 && ny < cols) { q.push({nx, ny}); } } } } } bool revealCell(int x, int y) { if (x < 0 || x >= rows || y < 0 || y >= cols) return true; if (board[x][y].revealed || board[x][y].flagged) return true; if (board[x][y].number == -1) { board[x][y].revealed = true; return false; } revealEmpty(x, y); return true; } bool toggleFlag(int x, int y) { if (x < 0 || x >= rows || y < 0 || y >= cols) return false; if (board[x][y].revealed) return false; board[x][y].flagged = !board[x][y].flagged; return true; } bool checkWin() { return safeCellsLeft == 0; } void run() { while (true) { displayBoard(); cout << "请输入操作 (o/f/q + 坐标): "; char ch; cin >> ch; if (cin.fail()) { cin.clear(); cin.ignore(4096, '\n'); continue; } if (ch == 'q') { cout << "游戏已退出\n"; break; } int x, y; cin >> x >> y; if (cin.fail()) { cin.clear(); cin.ignore(4096, '\n'); cout << "输入格式错误,请重新输入\n"; continue; } x--; y--; if (ch == 'f') { toggleFlag(x, y); } else if (ch == 'o') { if (firstClick) { placeMines(x, y); calcNumbers(); firstClick = false; } if (!revealCell(x, y)) { for (int i = 0; i < rows; i++) { for (int j = 0; j < cols; j++) { if (board[i][j].number == -1) { board[i][j].revealed = true; } } } displayBoard(); cout << "踩雷了!游戏结束。\n"; break; } if (checkWin()) { displayBoard(); cout << "恭喜,所有安全格子都翻开啦!\n"; break; } } else { cout << "未知命令,请使用 o / f / q\n"; } } } }; int main() { ios::sync_with_stdio(false); cin.tie(nullptr); Minesweeper game(9, 9, 10); game.showGameHelp(); game.run(); return 0; }这段代码我实测过,编译和运行都没有问题。棋盘默认9x9、10颗雷,是经典扫雷的入门配置。想调整难度,改主函数里Minesweeper的三个参数就行。
6. 运行实测与踩坑记录
6.1 输入缓冲与非法输入处理
第一次跑通后,我遇到的第一个坑是输入缓冲。玩家如果输入了字母而不是数字,比如输入o a b,cin >> x >> y会进入fail状态,这个状态如果不处理,后续所有cin操作都会失效,游戏看起来就像“卡死”了。
解决办法很简单,每次读取后检查cin.fail(),如果失败就调用cin.clear()恢复流状态,再用cin.ignore()把缓冲区里残留的内容清掉。这个处理我在run()里已经写好了。如果你自己改代码,切记不要把这几行删掉。
另外,在Windows控制台下如果遇到中文输出乱码,大概率是源码编码和控制台代码页不匹配。VSCode里配置C/C++环境后建议检查右下角的文件编码,要么统一用GBK保存源码,要么在命令行执行chcp 65001切到UTF-8,要么把输出换成英文。很多同学在这个问题上折腾很久,其实不是代码逻辑问题,就是编码没对齐。
6.2 VSCode 里配置 C++ 运行环境的注意事项
不少读者可能会直接在VSCode里编译运行这个项目。这里提几个我在VSCode里实测时的注意点。首先确认你已经安装了C/C++扩展和编译器,Windows下推荐MinGW-w64,macOS下可以用Clang,Linux下系统自带的g++就行。然后用终端命令编译,比依赖VSCode的tasks.json更直接:
g++ -std=c++17 -o minesweeper main.cpp ./minesweeper如果你用VSCode的“运行”按钮,要注意launch.json里的externalConsole设置,控制台程序有时候需要在外部终端运行,否则无法正常cin输入。这个问题的表现是程序启动后一闪而过,或者输入不进去,很多人以为代码写错了,其实只是调试器配置的问题。
6.3 后续可扩展的三个方向
这个基础版跑通之后,适合往三个方向扩展,难度依次递增。
第一,加入计时器。用std::chrono::steady_clock记录游戏开始时间,游戏结束后输出用时。如果想做成实时显示的计时器,可以引入std::thread开一个后台线程定时刷新屏幕,但控制台程序里线程和输入同时操作终端容易出现显示错乱,我建议先只用开始和结束两个时间点,更稳妥。
第二,加入存档功能。把board和safeCellsLeft按固定格式写入文本文件,下次启动时读取并恢复。这个扩展能让你练习文件读写和序列化,非常实用。
第三,升级图形界面。Windows下可以用EasyX,跨平台可以用SFML或Qt。改造的时候类里的逻辑部分可以基本不动,只需要把displayBoard()替换成图形渲染,把输入解析替换成鼠标点击事件。这也是我当时坚持把逻辑和渲染分开写的原因——写的时候多思考一步,后面扩展能省很大力气。
如果你也在学C++,想找个项目练手,我强烈推荐扫雷。它不像写一个完整业务系统那么复杂,但数据结构、算法、状态管理、异常处理这些核心基本功全都能练到。把代码敲一遍、跑通了之后,再回头去看那些C++八股文里的概念,你会发现自己真正理解它们了,而不是只停留在背诵的层面。
本文还有配套的精品资源,点击获取