news 2026/9/8 9:09:40

用C++从零实现控制台扫雷:二维数组、BFS与随机数实战指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
用C++从零实现控制台扫雷:二维数组、BFS与随机数实战指南

简介:一个基于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}); } } } } }

这里dxdy就是前面数字统计用的同一个偏移数组。写完这个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 bcin >> 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开一个后台线程定时刷新屏幕,但控制台程序里线程和输入同时操作终端容易出现显示错乱,我建议先只用开始和结束两个时间点,更稳妥。

第二,加入存档功能。把boardsafeCellsLeft按固定格式写入文本文件,下次启动时读取并恢复。这个扩展能让你练习文件读写和序列化,非常实用。

第三,升级图形界面。Windows下可以用EasyX,跨平台可以用SFML或Qt。改造的时候类里的逻辑部分可以基本不动,只需要把displayBoard()替换成图形渲染,把输入解析替换成鼠标点击事件。这也是我当时坚持把逻辑和渲染分开写的原因——写的时候多思考一步,后面扩展能省很大力气。

如果你也在学C++,想找个项目练手,我强烈推荐扫雷。它不像写一个完整业务系统那么复杂,但数据结构、算法、状态管理、异常处理这些核心基本功全都能练到。把代码敲一遍、跑通了之后,再回头去看那些C++八股文里的概念,你会发现自己真正理解它们了,而不是只停留在背诵的层面。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/8 9:09:34

图像倾斜矫正全解析:从投影法到Hough变换的工程实践

简介&#xff1a;一份用于图像倾斜矫正的C工程源码包&#xff0c;面向计算机视觉初学者及需要实现图像校正功能的开发者&#xff0c;提供从读取图像、检测倾斜角度到输出矫正结果的完整实现思路。压缩包共65个文件&#xff0c;约14.7MB&#xff0c;主要包含CPP/H源文件、BMP测试…

作者头像 李华
网站建设 2026/9/8 9:09:01

基于SSM框架的Java在线考试系统设计与核心实现解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/8 9:07:10

PyCharm内存修改不生效?一文搞懂JVM与VM Options正确配置

Pycharm越用越卡&#xff0c;项目一打开风扇就狂转&#xff0c;这应该是不少Java、Python开发者的共同记忆。很多人第一反应就是去Help菜单里找Edit Custom VM Options&#xff0c;把手里的2048改成4096甚至更高&#xff0c;保存、重启、满心期待性能起飞&#xff0c;结果一看H…

作者头像 李华
网站建设 2026/9/8 9:05:59

mRMR特征选择实战:最小冗余最大相关性实现数据瘦身

做特征选择这些年&#xff0c;我最大的感受是&#xff1a;“数据瘦身”这四个字听起来温柔&#xff0c;做起来相当残酷。模型训练之前&#xff0c;几百个特征摆在面前&#xff0c;哪些真正有用&#xff0c;哪些只是在陪跑、甚至帮倒忙&#xff0c;这件事没搞清楚&#xff0c;后…

作者头像 李华
网站建设 2026/9/8 9:04:14

为什么Spring不建议使用字段注入?六大缺陷与构造器注入实践指南

我最近在一个老项目里看到一张学生成绩表&#xff0c;字段命名相当随性——语文、数学、英语分别叫a1、a2、a3&#xff0c;注释一个没写&#xff0c;旁边人接手时全靠猜。这场景一出&#xff0c;我脑子里立刻蹦出另一件事&#xff1a;代码里随处可见的Autowired字段注入。很多 …

作者头像 李华
网站建设 2026/9/8 9:03:45

SOEM开源EtherCAT主站完全指南:从原理到STM32实战移植

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华