C++扫雷小游戏全解析:从二维数组到递归展开的练手项目
2026/9/13 13:06:44 网站建设 项目流程

如果你学过C++的语法,但始终觉得“学了个寂寞”,那C++炸弹小游戏就是动手练习里性价比最高的一类项目:代码量不大、逻辑完整、能玩、能展示。这里的“炸弹小游戏”指的就是经典扫雷,本质上是一道二维数组+递归+随机数的综合题。我当年在控制台里把它跑通的时候,那种“这个游戏是我写出来的”成就感,比刷十道算法题都来得实在。这篇文章就把这个项目的完整拆解、核心代码和踩坑记录整理出来,不管是刚学完语法不知道写什么的新手,还是想找个练手项目巩固基础的在校学生,都能直接照着做。


1. 项目概述:这个“小游戏”到底值不值得写

1.1 扫雷玩法规则:先从玩家视角建立需求

写代码之前,先把游戏规则搞清楚,这一步很多人会跳过,结果写出来的程序自己都不知道在干嘛。扫雷的规则其实很朴素:

棋盘上分布着若干颗地雷。玩家点开一个格子时,会出现两种情况:点到雷,游戏直接结束;没点到雷,格子上会显示一个数字,这个数字代表它周围八个格子中埋雷的数量。如果点开的格子周围没有雷,也就是数字为0,那它周围的一整片空白区域会自动展开,这是扫雷最核心的体验——一戳可以翻开一大片。玩家还可以给怀疑有雷的格子打标记,防止自己误点。最终目标不是把雷全部找出来,而是把所有不含雷的格子全部翻开,翻完就赢。

我第一次写扫雷的时候,把“把所有雷标记出来”当成胜利条件,结果玩家乱标几个旗子也能触发胜利,这个逻辑问题直到后来做了标记数量统计才意识到。胜利条件一定要基于“翻开非雷格的数量”来判断,而不是标记了多少个雷,这一点后面代码部分会细说。

1.2 技术选型:为什么用纯控制台而不是直接上图形库

网上有很多扫雷教程一上来就让你装图形库、建窗口,我看过太多初学者被环境配置劝退。C++炸弹小游戏这个项目,我强烈建议第一版就用纯控制台做,理由有三个:

  • 零依赖:只要有一个能编译C++的环境,无论是Windows下的Visual Studio、Dev-C++,还是Linux下的g++,把代码粘进去就能跑。不需要安装任何第三方库,也不用费心思配置链接器。
  • 聚焦逻辑:这个项目的核心价值在于游戏逻辑本身——棋盘建模、布雷、数字计算、递归展开、胜负判断。这些用控制台完全够用,一旦引入图形界面,注意力就不可避免地被窗口绘制、事件循环这些东西分散掉。
  • 便于调试:控制台程序想在哪打印就在哪打印,变量值、棋盘状态都能直接输出看。图形界面的调试成本要高得多,初学者很容易陷入“界面不出来”和“逻辑有bug”双重痛苦。

等第一版逻辑完全跑通,再去考虑SFML、Qt这些图形方案也不迟,那属于锦上添花,而不是雪中送炭。

1.3 知识点覆盖地图:一个游戏串起C++入门必备技能

这个项目最值钱的地方在于,它几乎把C++入门阶段该碰的知识点都串起来了。我做了一张对照表,你可以看看自己哪块是短板:

基础知识点在扫雷项目中的位置
二维数组与初始化棋盘建模的根基
函数封装与参数传递每个功能模块独立成函数
随机数与洗牌算法布雷环节
递归与回溯空白格自动展开
循环与条件控制主循环、边界判断、数字计算
输入输出流玩家交互、棋盘打印、清屏
STL容器vector/pair洗牌布雷、坐标存储
面向对象重构进阶版封装Minesweeper类

换句话说,如果你能把扫雷完整写出来,你的C++基础语法绝对过关了。很多面试题里考的二三数组操作、递归边界处理,在这个小项目里都是实打实练过的,不是背八股文死记的那种。


2. 核心数据与算法拆解:先画好棋盘再谈玩法

2.1 棋盘建模:两张二维数组,逻辑与显示分离

扫雷最核心的数据结构就是棋盘。新手通常只想到用一个二维数组来表示格子,这个方向没错,但实际操作时你会发现一个数组根本不够用。

我给这个项目设计了两个二维数组:一个叫逻辑棋盘,一个叫显示棋盘。它们各司其职,分开管理。

逻辑棋盘存的是“真实世界”的数据:地雷的位置、每个格子周围的地雷数量。显示棋盘存的是“玩家视角”的数据:哪些格子还没翻开、哪些被标记了旗子、哪些已经显示数字。

为什么要分开?可以参考外卖App的“商家后台库存”和“顾客页面显示”的关系。后台库存是真实数据,顾客页面是给用户看的快照。如果你把库存和页面显示混在一起,任何一端改动都会牵连另一端,游戏逻辑很快会乱成一团。

具体定义大致是:

const int ROWS = 10; const int COLS = 10; const int MINE_COUNT = 10; int mapBoard[ROWS][COLS]; // 逻辑棋盘:-1表示雷,>=0表示周围雷数 char showBoard[ROWS][COLS]; // 显示棋盘:'#'未翻开,'F'标记,'0'-'8'数字 bool visited[ROWS][COLS]; // 防止递归展开时重复访问

mapBoard用整数数组,是因为它既要存“是不是雷”,还要存“周围有几颗雷”,一个char放不下这个信息量;showBoard用字符数组,是因为打印的时候直接就能输出,省去转换。

2.2 布雷算法:从低效重试到Fisher-Yates洗牌

布雷的方法,网上的教程大多这么写:循环随机生成坐标,如果这个格子还没放过雷,就放一颗,直到放满。

void placeMinesByRandom() { int placed = 0; while (placed < MINE_COUNT) { int r = rand() % ROWS; int c = rand() % COLS; if (mapBoard[r][c] != -1) { mapBoard[r][c] = -1; placed++; } } }

这个方案实现简单,但有一个隐藏的性能隐患:当棋盘上一大半格子都已经是雷的时候,随机重复的概率会变得很高,重试次数飙升。如果棋盘是100x100且要放9000颗雷,这个循环可能要跑几万次才结束。

更靠谱的做法是把所有格子的坐标放进一个容器,然后随机打乱,取前MINE_COUNT个作为地雷位置。这里借助STL的shuffle函数,一行调用就完成洗牌:

void placeMinesByShuffle() { vector<pair<int, int>> cells; for (int i = 0; i < ROWS; ++i) { for (int j = 0; j < COLS; ++j) { cells.push_back({i, j}); } } random_device rd; mt19937 g(rd()); shuffle(cells.begin(), cells.end(), g); for (int k = 0; k < MINE_COUNT; ++k) { mapBoard[cells[k].first][cells[k].second] = -1; } }

洗牌法有三个好处:一是不会重复选中同一个坐标,天然去重;二是效率稳定,不管地雷数是10个还是9000个,时间复杂度都是O(rows * cols);三是从C++11开始,random_device和mt19937的质量比老式rand()好太多,不会出现同一秒内开局雷位完全一样的情况。

2.3 邻域数字计算:方向数组是解八邻域问题的钥匙

雷布好之后,还要计算每个非雷格子周围有几颗雷。这个问题本质上是二维数组的八邻域遍历。常见做法是写一个方向数组,把八个方向的偏移量一次性存起来,然后用循环统一处理。

const int DIRS[8][2] = { {-1, -1}, {-1, 0}, {-1, 1}, { 0, -1}, { 0, 1}, { 1, -1}, { 1, 0}, { 1, 1} }; void calNumbers() { for (int i = 0; i < ROWS; ++i) { for (int j = 0; j < COLS; ++j) { if (mapBoard[i][j] == -1) continue; int cnt = 0; for (int d = 0; d < 8; ++d) { int ni = i + DIRS[d][0]; int nj = j + DIRS[d][1]; if (ni < 0 || ni >= ROWS || nj < 0 || nj >= COLS) continue; if (mapBoard[ni][nj] == -1) cnt++; } mapBoard[i][j] = cnt; } } }

方向数组这种写法,以后做迷宫寻路、图像处理、BFS遍历都会反复用到,建议一次练熟。边界判断一定要在访问数组之前做,这是新手最容易踩的坑:不判断ni和nj是否越界,直接mapBoard[ni][nj],轻则读到错误数据,重则数组越界崩溃。

2.4 递归展开:flood fill的递归实现与边界控制

扫雷体验最爽的部分,就是点开一个空白格,周围一整片区域“哗”地全部翻开。这个功能在算法上叫flood fill,用递归实现非常直观。

展开逻辑是这样的:当前格子翻开后,如果它周围的雷数为0,就继续翻开它周围八个格子;新翻开的格子如果还是0,再继续扩散;如果新翻开的格子有数字(大于0),就停下来,不再往外引。

void revealCell(int r, int c) { if (r < 0 || r >= ROWS || c < 0 || c >= COLS) return; if (showBoard[r][c] != '#') return; if (mapBoard[r][c] == -1) return; showBoard[r][c] = static_cast<char>('0' + mapBoard[r][c]); if (mapBoard[r][c] > 0) return; for (int d = 0; d < 8; ++d) { revealCell(r + DIRS[d][0], c + DIRS[d][1]); } }

这个递归函数有三个前置守卫:坐标越界直接return,格子已经翻开或标记了直接return,遇到雷直接return。这三个条件缺一不可,否则函数会在边缘区域无限递归,或者把已经翻开的格子再翻一遍,甚至在递归过程中误处理雷格子。

你可能注意到这里没有用到visited数组,因为showBoard[r][c] != '#'本身就起到了防重入的作用。visited数组在递归写法里是多余的,但在后文提到的迭代版展开中会用到,或者你也可以用它来标记那些已经确定的区域,看个人习惯。


3. 手写实现:完整跑通一个可玩的扫雷

3.1 环境准备与初始工程结构

这个项目对环境要求非常宽容,只要能编译C++11以上标准的工具都行。我自己平时常用VS Code + MinGW组合,命令大概是:

g++ minesweeper.cpp -o minesweeper -std=c++17

Windows用户用Visual Studio直接新建空控制台项目,Dev-C++用户直接新建源文件编译即可。不建议一上来就折腾CMake,这个体量的项目没必要。

代码组织建议单文件实现,全局常量和管理函数分开写清楚。等逻辑稳定后再考虑拆成多个文件或封装类。我习惯的顺序是:头部的常量和包含,然后是核心函数声明,再按“初始化→布雷→数字→打印→交互→主循环”的顺序实现。

3.2 核心函数逐个实现:初始化、布雷、计算、打印

初始化函数负责把两个棋盘清空、显示棋盘全部填上未翻开标记。这里有一个细节:memset对int数组和bool数组都有效,但char数组也可以直接用,只要你的目标状态是0或空字符。

void initGame() { memset(mapBoard, 0, sizeof(mapBoard)); memset(visited, 0, sizeof(visited)); for (int i = 0; i < ROWS; ++i) { for (int j = 0; j < COLS; ++j) { showBoard[i][j] = '#'; } } }

初始化完,依次调用布雷和计算数字即可。

打印函数是调试时最重要的工具,一定要把棋盘边界和行列号打出来,否则玩家无法输入坐标。我第一版没打印行列号,结果全靠自己数格子,玩起来非常痛苦。

void printBoard() { cout << " "; for (int j = 0; j < COLS; ++j) { cout << setw(2) << j; } cout << "\n"; for (int i = 0; i < ROWS; ++i) { cout << setw(2) << i; for (int j = 0; j < COLS; ++j) { cout << setw(2) << showBoard[i][j]; } cout << "\n"; } }

这里用setw保证列对齐,需要包含iomanip头文件。显示棋盘里存的是字符,直接用showBoard[i][j]输出即可,不用做任何转换。

3.3 主循环与用户输入:把游戏“焊”起来

游戏的主循环本质上是一个状态机:循环读取玩家输入,根据输入更新棋盘状态,检查胜负条件,刷新显示。输入设计我采用两套指令:

  • 输入x y:翻开坐标为(x, y)的格子
  • 输入f x y:标记或取消标记坐标为(x, y)的格子

用cin >>直接读取,好处是自动跳过空白字符和换行符,不用手动处理残留的回车。

while (true) { clearScreen(); printBoard(); cout << "操作:x y 翻开 | f x y 标记 | q 退出 > "; char op; cin >> op; if (op == 'q') break; int r, c; cin >> r >> c; if (r < 0 || r >= ROWS || c < 0 || c >= COLS) { cout << "坐标越界,请重新输入。\n"; system("pause"); continue; } if (op == 'f') { if (showBoard[r][c] == '#') showBoard[r][c] = 'F'; else if (showBoard[r][c] == 'F') showBoard[r][c] = '#'; continue; } if (showBoard[r][c] != '#') { cout << "该格子已翻开,请重新输入。\n"; system("pause"); continue; } if (mapBoard[r][c] == -1) { // 游戏结束:翻开所有雷,显示失败 gameOver = true; break; } revealCell(r, c); if (checkWin()) { gameOver = true; win = true; break; } }

这里有两个玩家体验细节值得提:一是对重复翻开的格子要做拦截,否则玩家误操作会把已翻开的格子再处理一遍;二是输入坐标后先做合法性检查,别让越界值进入后续逻辑。

3.4 终局体验:胜负判断与重新开局的完整处理

胜负判断的准确写法是统计当前已翻开的非雷格数量。前面提过,不能把“标记了所有雷”当成胜利条件,因为玩家可能乱标旗子。

正确做法是在每次翻开格子后计数,当翻开数量达到“总格子数-地雷数”时,判定胜利。

bool checkWin() { int revealed = 0; for (int i = 0; i < ROWS; ++i) { for (int j = 0; j < COLS; ++j) { if (showBoard[i][j] != '#' && showBoard[i][j] != 'F') { revealed++; } } } return revealed == ROWS * COLS - MINE_COUNT; }

注意这里判断条件必须是!= '#' && != 'F',也就是只统计真正被翻开的格子。如果把标记的旗子也算进去,玩家标记10个雷后一个格子都没翻开就能触发胜利。

游戏结束后,无论是输还是赢,都应该把逻辑棋盘上所有雷的位置展示出来。做法是遍历mapBoard,把值为-1的格子位置在showBoard里改为'*',再调用一次printBoard。

重新开局的流程也别省略:释放资源(如果用new申请过内存)、清空残局数据、重新调用initGame和布雷函数即可。我在循环外面套了一个do-while,询问玩家是否再来一局,直接复用initGame逻辑,非常省事。


4. 踩坑实录:这些bug我猜你也会遇到

4.1 随机数“不随机”:srand放错位置的结果

我最初用的是rand() % ROWS来生成雷坐标,然后在main函数开头调用了srand(time(0))。问题出在写“再来一局”功能时:我在重启游戏的函数里又调了一次srand(time(0)),结果两次开局发生在同一秒内,雷的位置完全一样。

这个坑的诊断过程很有意思:先以为是内存没清干净,反复检查数组初始化,后来在布雷函数里打印随机种子才发现是srand重复调用。解决方式很简单:整个程序生命周期里,srand只调用一次就够了。更现代的写法是用random_devicemt19937,它们不需要手动设置种子,质量也更好。

4.2 边界越界:多算一行/一列引发的花屏

第一次写calNumbers时,方向数组和边界判断逻辑是这样的:先访问mapBoard[ni][nj],再判断ni和nj是否越界。顺序反了。结果在棋盘最边上的格子,访问到了数组外面的内存,读出来的数字混乱不堪,有时候是巨大的负数,有时候是随机值。

这种越界错误通常不会立即崩溃,但会以各种诡异的方式出现在游戏里:数字不对、棋盘边缘莫名多出雷、在某些位置点开格子导致程序无响应。排查方法很简单:把打印函数里mapBoard的每个格子都打出来,和布雷数据对照看,立刻就知道哪些位置被污染了。修复方式就是把边界判断放在数组访问之前,这个习惯值得养成。

4.3 输入缓冲:cin残留让游戏一秒闪退

新手常犯的一个错误是在一次循环里混用cin >>cin.getline()cin >>在遇到空格或换行时会停下来,但不会消费掉换行符,残留的换行符如果被接下来的getline读到,整个输入流程就会错乱。

我的经验是:既然这个项目只需要数字和单个字符输入,就全程用cin >>,不要混用getline。如果因为某些原因必须清空缓冲区,用:

cin.ignore(1024, '\n');

这个语句会忽略掉缓冲区里最多1024个字符,直到遇到换行符才停下来。不过需要注意,如果调用结束后缓冲区里没有残留换行,反而会把下一次有效输入的第一个回车吃掉,所以别随便加。

4.4 递归风险:当展开遇到大面积空白

标准扫雷的10x10棋盘,递归展开的深度一般不会超过几十层,不用担心栈溢出。但人总是有折腾精神的,我有一次为了测试把棋盘改成了1000x1000且布满了接近空白的布局,点开一个格子后递归深度飙升,程序直接栈溢出报错。

这种情况可以把递归改成迭代:用一个vector模拟栈,每次弹出栈顶坐标处理,再把需要扩散的相邻坐标压入栈中,直到栈空。逻辑和递归完全一致,但栈空间由vector动态维护,可以处理更大的规模。

void revealCellIter(int sr, int sc) { vector<pair<int, int>> stk = { {sr, sc} }; while (!stk.empty()) { auto [r, c] = stk.back(); stk.pop_back(); if (r < 0 || r >= ROWS || c < 0 || c >= COLS) continue; if (showBoard[r][c] != '#') continue; if (mapBoard[r][c] == -1) continue; showBoard[r][c] = static_cast<char>('0' + mapBoard[r][c]); if (mapBoard[r][c] > 0) continue; for (int d = 0; d < 8; ++d) { stk.push_back({r + DIRS[d][0], c + DIRS[d][1]}); } } }

这里的auto [r, c]是C++17的结构化绑定语法,如果你的编译器不支持C++17,可以用pair<int,int> cur = stk.back();然后手动取first和second。

4.5 常见问题速查表

问题现象可能原因解决方式
雷的位置重复或每次开局都一样srand在循环中重复调用程序开头只调用一次srand,或改用random_device
边缘格子数字乱显示边界判断在数组访问之后先判断坐标越界,再访问数组
输入后程序没反应或闪退cin和getline混用导致残留全程用cin >>,必要时用cin.ignore
点空白格后程序崩溃递归展开过深导致栈溢出改用迭代版展开
标记所有雷后直接胜利胜利判断条件错误只统计已翻开的非雷格数量
控制台中文乱码Windows终端编码不匹配用SetConsoleOutputCP(CP_UTF8)或全英文界面

5. 还能怎么玩:从“能玩”到“好用”的进阶方向

5.1 功能扩展:计时器、标记数与难度档位

基础版本跑通之后第一时间可以做的三个小扩展:

  • 计时功能:开局时记录clock()时间,在玩家胜利时计算耗时。控制台扫雷没有秒表就少了一半紧张感。
  • 标记计数:统计玩家用了多少个旗子,并实时显示“已标记/总雷数”,玩家能快速知道自己漏了几颗雷。
  • 难度档位:把棋盘尺寸和地雷数做成可选配置,参考经典扫雷的布局:初级9x9/10雷,中级16x16/40雷,高级16x30/99雷。实现方式很简单,把原来写死的ROWS、COLS、MINE_COUNT三个常量改成变量,开局前让玩家选择。

这三个功能每个改动量都不大,但能让游戏完整度上一个台阶。做完之后我强烈建议你找室友或者朋友玩一局,别人真实的操作习惯能让你发现一堆自己没考虑到的边界情况。

5.2 代码重构:用面向对象把游戏逻辑封装起来

全局变量版扫雷最大的问题是:所有函数都直接操作全局数组,代码一多就分不清状态边界。这时候可以做一个Minesweeper类,把棋盘、常量、所有操作函数收进类内部。

关键改动思路:

  • 成员变量改成vector<vector<int>> mapBoardvector<vector<char>> showBoard,动态尺寸由构造函数的参数决定。
  • 布雷、计算数字、展开、判断胜负都变成成员函数。
  • 主循环只调用类的公开接口:initGame()、reveal()、flag()、checkWin()、print()。
  • mapBoard和showBoard设为private,外部不能直接操作,保证数据安全。

这个重构过程其实就是C++面向对象入门的最佳练习:你会遇到拷贝问题、访问权限问题、初始化列表问题,每一个都是面试常考的点。

5.3 图形化的路径选择:从控制台走向窗口

控制台版做过一遍之后,如果想进一步挑战图形界面,我推荐的第一个方向是SFML。它比Qt轻量,比Win32 API现代,跨平台,官网还自带中文教程,学习曲线比较平缓。把扫雷从控制台搬到SFML大约需要做的事情是:用sf::RectangleShape画格子、用sf::Mouse::getPosition处理点击、用sf::Texture加载雷和数字的贴图。

也可以试试直接用Qt的QPushButton矩阵或者Qt Graphics View框架,适合想走桌面开发方向的人。比SFML重一些,但控件拖拽式开发对扫雷这种棋盘类游戏很友好。

这一步的难点不在图形库本身,而在于如何把已经写好的纯逻辑代码和UI层解耦。如果你在控制台版里就有意识地用两个棋盘分开管理逻辑和显示,迁移到图形界面会轻松非常多——这也是我为什么在最开始就强调逻辑与显示分离的原因。


这个项目我前前后后带人写过好几遍,每次都能发现新的问题,也从侧面说明扫雷真的是一个“麻雀虽小五脏俱全”的练手项目。如果你能不看任何参考代码,把一个能正常玩的扫雷完整写出来,你对C++基础语法的掌握程度绝对不差。最后说一个小技巧:改bug的时候别只顾盯着代码看,把棋盘的打印函数做扎实,print大法在开发中永远是最管用的武器。祝你们都能写出自己的第一个小游戏。

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

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

立即咨询