资讯动态

C++实战:从零实现控制台扫雷,掌握递归与二维数组

发布时间:2026/10/16 13:39:38 来源:尧图企业网站定制
1. 项目缘起与整体设计思路扫雷这个游戏估计只要碰过电脑的人都不陌生。它不像那些大型3D游戏需要显卡满载运行也不像某些竞技游戏需要网络低延迟支持它就是一个纯粹的、安静的、考验逻辑推理能力的小工具。但恰恰是这种“简单”的游戏用来练手C是再合适不过了。我当初选择用C重写扫雷核心目的就一个把面向对象、二维数组操作、递归算法、控制台交互这几块知识串起来做一个能跑、能玩、代码结构还算过得去的完整项目。这个项目的目标人群很明确。如果你刚学完C基础语法知道什么是类、什么是数组、什么是函数但还没完整做过一个项目那扫雷就是你的最佳练手选择。它不像学生管理系统那样枯燥也不像贪吃蛇那样逻辑过于线性。扫雷涉及随机布雷、周围雷数计算、空白区域自动展开、胜负判定这几个核心模块每一个都有值得深挖的细节。整体设计上我采用的是控制台字符界面方案。为什么不做图形界面原因很直接图形界面需要引入额外的库比如EasyX或者Qt这会分散你对核心逻辑的注意力。控制台方案只需要标准库iostream、vector、cstdlib、ctime这几个头文件就够了代码量可控调试也方便。你可以在任何一台装了编译器的机器上跑起来不需要配置额外的环境。数据结构方面我用了一个Cell结构体来表示每个格子的状态包含三个核心字段isMine标记是否是雷isRevealed标记是否已被翻开adjacentMines存储周围雷的数量。棋盘本身用一个二维vectorvectorCell来管理。你可能会问为什么不用原生二维数组因为vector可以动态调整大小方便我后续做难度选择功能而且传参的时候不用担心数组退化成指针的问题。游戏流程的设计遵循经典的扫雷规则玩家输入坐标程序判断该位置是否是雷。如果是雷游戏结束如果不是计算周围雷数并显示。如果周围雷数为零自动递归展开相邻的空白区域。当所有非雷格子都被翻开时玩家获胜。这个流程看起来简单但每一步都有坑后面我会逐一拆解。注意控制台扫雷的输入方式需要特别设计。直接用cin row col虽然简单但玩家输入错误格式时会导致输入流进入错误状态后续所有输入都会失败。这个问题我在第一次实现时就踩过排查了半天才发现是流状态没有重置。2. 核心模块拆解与关键实现细节2.1 棋盘初始化与随机布雷的学问布雷是扫雷的第一个核心环节。表面上看不就是随机选几个位置放雷吗但实际操作中有几个细节直接影响到游戏体验。首先是布雷时机的选择。有两种方案一是在游戏开始前就把雷全部布好二是在玩家第一次点击之后再布雷。我选择的是第一种方案原因很简单——实现起来更直观。但这里有一个经典问题如果玩家第一次点击就踩雷游戏直接结束体验极差。所以我在布雷之后加了一个保护逻辑如果玩家第一次翻开的格子恰好是雷就把这颗雷移到棋盘上第一个非雷的位置。这个操作在代码里就是一个简单的交换。其次是随机数的质量。很多教程直接用rand() % rows来生成随机坐标但这样做有两个问题。第一rand()在没有调用srand()之前每次运行产生的序列是一样的也就是说每局游戏的雷的位置完全相同。第二rand()的低位随机性较差取模之后分布不均匀。我的做法是用std::mt19937配合std::uniform_int_distribution这是C11引入的随机数设施质量和均匀性都有保障。std::random_device rd; std::mt19937 gen(rd()); std::uniform_int_distributionint rowDist(0, rows - 1); std::uniform_int_distributionint colDist(0, cols - 1);布雷的数量根据难度来定。我设了三档初级9x9放10颗雷中级16x16放40颗雷高级16x30放99颗雷。这个配置和经典扫雷保持一致玩家上手时不会有陌生感。2.2 周围雷数计算与边界处理计算每个格子周围的雷数是扫雷里最容易写出bug的地方。一个格子最多有8个邻居分布在上下左右和四个对角方向。最直观的写法是写8个if判断但这样代码冗长且容易漏掉某个方向。我的做法是用两个数组来表示8个方向的偏移量const int dx[] {-1, -1, -1, 0, 0, 1, 1, 1}; const int dy[] {-1, 0, 1, -1, 1, -1, 0, 1};然后用一个循环遍历这8个方向每次检查新坐标是否在棋盘范围内。这种写法的好处是逻辑清晰扩展性强。如果以后要做六边形棋盘只需要改偏移量数组就行了。边界处理是这里的核心难点。假设棋盘是9x9某个格子在(0,0)位置它的上方和左方都超出了棋盘范围。如果不做边界检查程序会访问越界内存轻则数据错误重则直接崩溃。我在代码里封装了一个isValid函数bool isValid(int r, int c) { return r 0 r rows c 0 c cols; }每次计算邻居坐标后先调用这个函数判断合法性再决定是否累加雷数。这个检查看起来简单但绝对不能省。我见过不少初学者写的扫雷在角落格子处会出现莫名其妙的数字十有八九就是边界没处理好。2.3 空白区域自动展开的递归实现空白区域自动展开是扫雷最核心的算法也是递归思想最典型的应用场景。当玩家翻开的格子周围雷数为零时程序需要自动翻开它周围的所有格子。如果这些格子中又有周围雷数为零的继续递归展开直到遇到数字格子为止。递归函数的逻辑是这样的void revealCell(int r, int c) { if (!isValid(r, c)) return; if (board[r][c].isRevealed) return; if (board[r][c].isMine) return; board[r][c].isRevealed true; if (board[r][c].adjacentMines 0) { for (int i 0; i 8; i) { revealCell(r dx[i], c dy[i]); } } }这段代码看起来简洁但有两个关键点需要注意。第一递归的终止条件必须完整。isRevealed检查防止重复访问isMine检查防止翻开雷isValid检查防止越界。三个条件缺一不可。第二递归深度的问题。在高级难度下如果雷的分布比较稀疏一次展开可能涉及上百个格子递归深度可能达到几十层。对于现代计算机来说这个深度完全在安全范围内不会导致栈溢出。但如果你真的担心递归深度也可以改成用队列实现的广度优先搜索。我用递归是因为代码更短可读性更好。实测下来即使在16x30的高级棋盘上递归展开也没有出现过任何性能问题。实操心得递归展开的时候一定要先标记当前格子为已翻开再递归周围的格子。如果顺序反了会导致无限递归。我第一次写的时候就是把标记放在递归调用之后结果程序直接卡死调试器显示栈溢出。2.4 胜负判定与游戏循环设计胜负判定看起来简单但要做到准确无误也需要仔细设计。胜利条件是所有非雷格子都被翻开。我的实现方式是维护一个计数器revealedCount每翻开一个非雷格子就加一。当revealedCount等于总格子数减去雷数时玩家获胜。失败条件更直接玩家翻开的格子是雷。但这里有一个细节——如果玩家使用标记功能标出了所有雷的位置是否应该自动获胜经典扫雷的规则是不自动获胜必须把所有非雷格子翻开才算赢。我遵循了这个规则因为标记功能只是辅助记忆不应该成为获胜的捷径。游戏主循环的结构是这样的while (gameState PLAYING) { printBoard(); int r, c; char action; std::cout 输入操作(r c a: 翻开/标记): ; std::cin r c action; if (action f) { revealCell(r, c); } else if (action m) { toggleFlag(r, c); } checkGameState(); }这个循环里有一个容易忽略的问题输入验证。如果玩家输入了超出棋盘范围的坐标或者输入了非数字字符程序不能崩溃而应该给出提示并让玩家重新输入。我在实际测试中发现很多初学者写的扫雷在输入错误时直接进入死循环原因就是cin的失败状态没有被清除。3. 完整实操流程与核心代码实现3.1 项目文件结构与编译配置整个项目我放在一个文件夹里结构非常简单minesweeper/ ├── main.cpp ├── board.h ├── board.cpp └── CMakeLists.txtboard.h里定义了Cell结构体和Board类board.cpp实现了所有核心逻辑main.cpp负责游戏循环和用户交互。这种分文件的方式让代码结构清晰也方便你单独测试每个模块。编译方面我提供了两种方式。如果你用命令行直接g -stdc17 -O2 main.cpp board.cpp -o minesweeper如果你用CMakeCMakeLists.txt的内容如下cmake_minimum_required(VERSION 3.10) project(Minesweeper) set(CMAKE_CXX_STANDARD 17) add_executable(minesweeper main.cpp board.cpp)选择C17标准是因为我用到了std::optional来做一些返回值处理这个特性在C17里才正式引入。如果你用的是老编译器把std::optional换成普通的bool返回值也能跑。3.2 棋盘类的完整接口设计Board类的接口设计遵循“最小暴露原则”只把必要的操作暴露给外部内部状态全部私有化。核心接口包括接口名称参数返回值功能说明init行数、列数、雷数void初始化棋盘并布雷reveal行、列bool翻开格子返回是否踩雷toggleFlag行、列void切换标记状态isWin无bool判断是否获胜print无void打印当前棋盘状态reveal函数返回一个bool值表示是否踩雷。这样设计的好处是游戏主循环可以根据返回值直接判断游戏状态不需要再去查询棋盘内部数据。bool Board::reveal(int r, int c) { if (!isValid(r, c)) return false; if (board[r][c].isRevealed) return false; if (board[r][c].isFlagged) return false; if (board[r][c].isMine) { board[r][c].isRevealed true; return true; // 踩雷了 } revealCell(r, c); return false; }3.3 打印棋盘的美化处理控制台打印棋盘最怕的就是密密麻麻一片字符玩家根本分不清哪里是哪里。我在打印上做了几个优化。第一用不同的字符区分状态。未翻开的格子用#表示已翻开的空白格子用.表示数字格子直接显示数字标记的格子用F表示。这样一眼就能看出棋盘的整体状态。第二加上行列标号。在棋盘上方打印列号在左侧打印行号玩家输入坐标时可以直接对照不用自己数格子。第三用空格分隔每个格子。# # # #比####看起来舒服得多尤其是在16x30的大棋盘上空格分隔能显著提升可读性。void Board::print() { std::cout ; for (int c 0; c cols; c) { std::cout std::setw(2) c ; } std::cout \n; for (int r 0; r rows; r) { std::cout std::setw(2) r ; for (int c 0; c cols; c) { if (!board[r][c].isRevealed) { std::cout (board[r][c].isFlagged ? F : # ); } else if (board[r][c].isMine) { std::cout * ; } else if (board[r][c].adjacentMines 0) { std::cout . ; } else { std::cout board[r][c].adjacentMines ; } } std::cout \n; } }注意std::setw需要包含iomanip头文件。这个格式化输出的工具在控制台程序里非常实用能让输出对齐看起来更专业。3.4 第一次点击保护机制的实现前面提到了第一次点击保护这里展开说一下具体实现。在init函数布雷之后我记录了一个firstClick标志。当玩家第一次调用reveal时如果翻开的格子是雷就执行一个relocateMine操作遍历棋盘找到第一个非雷且未被翻开的格子把雷移过去然后重新计算所有格子的周围雷数。void Board::relocateMine(int r, int c) { for (int i 0; i rows; i) { for (int j 0; j cols; j) { if (!board[i][j].isMine !(i r j c)) { board[i][j].isMine true; board[r][c].isMine false; recalculateAdjacent(); return; } } } }recalculateAdjacent函数会遍历整个棋盘重新计算每个格子的adjacentMines值。这个操作的时间复杂度是O(rows * cols * 8)在16x30的棋盘上也就是几千次操作完全不影响性能。这个保护机制看起来只是一个小优化但对游戏体验的提升非常明显。没有它玩家开局就踩雷的概率大约是雷数除以总格子数初级难度下大约是12%十局里至少有一局开局就结束非常打击积极性。4. 常见问题排查与避坑经验实录4.1 输入流错误导致的死循环这是控制台程序最经典的问题没有之一。当玩家输入了一个字母而不是数字时cin会进入失败状态后续所有的输入操作都会被跳过程序进入死循环。解决方法是在每次输入后检查流状态如果失败就清除状态并忽略缓冲区中的内容if (std::cin.fail()) { std::cin.clear(); std::cin.ignore(std::numeric_limitsstd::streamsize::max(), \n); std::cout 输入无效请重新输入。\n; continue; }std::numeric_limitsstd::streamsize::max()需要包含limits头文件。这个写法看起来有点绕但它是清除输入缓冲区的标准做法比固定写一个很大的数字更可靠。4.2 递归展开时的重复访问问题前面提到过递归展开需要检查isRevealed但还有一个隐蔽的问题如果两个相邻的空白格子互相递归会不会导致无限循环答案是不会因为第一个格子被翻开后立即标记为isRevealed第二个格子递归到第一个格子时会直接返回。但前提是标记操作必须在递归调用之前执行。我见过有人把标记操作放在递归之后想着“先展开周围的再标记自己”结果就是两个空白格子互相调用栈直接溢出。这个问题的排查难度在于程序不是每次都崩溃只有当两个空白格子相邻时才会触发测试的时候很容易漏掉。4.3 棋盘打印的性能问题在高级难度下棋盘有16行30列总共480个格子。每次打印都要遍历所有格子输出几百个字符。如果游戏循环里每操作一步就全量打印在终端里会感觉明显卡顿。我的优化方案是只打印变化的部分但这需要用到终端控制序列代码复杂度会上升。对于练手项目来说全量打印已经足够了。如果你真的觉得卡可以把std::cout换成printf或者用std::ostringstream先拼接好字符串再一次性输出减少IO调用次数。4.4 常见问题速查表问题现象可能原因排查方法解决方案程序启动后直接崩溃数组越界访问用调试器查看崩溃位置检查所有数组访问的边界条件每局雷的位置相同未初始化随机种子检查是否调用了srand或mt19937使用std::random_device初始化空白区域展开不完整递归终止条件缺失检查isRevealed和isValid判断补全所有终止条件输入坐标后无响应输入流进入失败状态检查cin.fail()清除流状态并忽略缓冲区角落格子雷数错误边界检查遗漏打印角落格子的邻居坐标确保每次访问前调用isValid标记后无法翻开标记状态未正确切换检查toggleFlag逻辑确保标记和翻开互斥4.5 几个提升代码质量的小技巧第一个技巧是用enum代替魔法数字。游戏状态我用enum GameState { PLAYING, WIN, LOSE }来表示比用0、1、2直观得多。代码里看到gameState WIN比看到gameState 1清晰一百倍。第二个技巧是把常量提取到文件顶部。棋盘的最大行数、最大列数、各难度的雷数这些都用constexpr定义成常量。以后想调整难度只需要改一个地方不用在代码里到处找。第三个技巧是给关键函数写注释。不是那种“这是一个函数”的废话注释而是解释“为什么这样做”的注释。比如第一次点击保护那里我会写“防止玩家开局踩雷导致体验过差”这样以后回头看代码时能快速理解设计意图。实操心得调试扫雷的时候我建议加一个“上帝模式”按一个特定键就能显示所有雷的位置。这个功能在开发阶段非常有用可以快速验证布雷和雷数计算是否正确。发布版本里把这个功能去掉就行了。5. 功能扩展与进阶方向5.1 加入计时器和排行榜经典扫雷有计时功能记录玩家完成一局用了多少秒。在控制台程序里实现计时器可以用chrono库。在游戏开始时记录一个时间点游戏结束时再记录一个时间点两者相减就是用时。auto start std::chrono::steady_clock::now(); // ... 游戏循环 ... auto end std::chrono::steady_clock::now(); auto duration std::chrono::duration_caststd::chrono::seconds(end - start); std::cout 用时: duration.count() 秒\n;排行榜可以用一个简单的文本文件来存储每行记录“难度 用时”。读取时按用时排序显示前五名。这个功能不复杂但能让单机游戏多一个反复挑战的理由。5.2 支持鼠标操作的可能性控制台程序默认只支持键盘输入但如果你在Windows环境下可以用windows.h里的GetCursorPos和GetAsyncKeyState来获取鼠标位置和点击事件。不过这样做会让代码失去跨平台性而且控制台窗口的坐标映射需要额外处理。我的建议是如果你真的想加鼠标操作不如直接上图形界面库。EasyX是一个轻量级的C图形库学习曲线平缓用它重写扫雷的界面部分核心逻辑代码几乎不用改。这样既保留了控制台版本的核心算法又获得了更好的交互体验。5.3 用面向对象重构代码结构目前的代码虽然用了类但逻辑和界面还是耦合在main.cpp里。如果想进一步提升代码质量可以把界面渲染抽象成一个Renderer接口控制台版本实现一个ConsoleRenderer图形版本实现一个GraphicRenderer。这样核心逻辑完全不需要关心界面是怎么实现的符合单一职责原则。这个重构听起来有点过度设计但对于学习设计模式来说是一个很好的练习。你可以先写一个简单的接口然后分别实现两个渲染器体会一下多态带来的灵活性。5.4 单元测试的引入扫雷的核心逻辑非常适合写单元测试。布雷是否数量正确、雷数计算是否准确、递归展开是否完整、胜负判定是否无误这些都可以用测试用例来验证。C的单元测试框架我推荐Catch2它只需要一个头文件不需要额外编译库非常适合小项目。#define CATCH_CONFIG_MAIN #include catch.hpp TEST_CASE(Board initialization, [board]) { Board b; b.init(9, 9, 10); REQUIRE(b.getMineCount() 10); }引入测试之后每次修改代码都可以快速验证核心逻辑没有被破坏这对于逐步添加新功能非常有帮助。5.5 代码优化与性能调优对于扫雷这种规模的程序性能优化其实不是重点但有几个地方可以做得更优雅。比如recalculateAdjacent函数每次布雷后都要全量重算其实可以只更新受影响的格子。不过考虑到棋盘最大也就480个格子全量重算的开销完全可以接受没必要为了微小的性能提升增加代码复杂度。真正值得优化的是内存布局。vectorvectorCell在内存中不是连续的每个内层vector都有独立的内存块。如果改成用一个一维数组模拟二维内存访问的局部性会更好。但对于扫雷这种小规模数据这点差异可以忽略不计。我个人的体会是练手项目的重点在于逻辑正确和代码清晰性能优化可以放到后面。先把功能做对再考虑做快。过早优化是万恶之源这句话在扫雷项目上同样适用。最后分享一个我在调试时常用的小技巧在revealCell函数里加一行日志输出记录每次递归展开的坐标。这样当展开结果不符合预期时你可以清楚地看到递归的调用路径快速定位是哪个格子的判断出了问题。日志用#ifdef DEBUG包起来发布版本自动去掉不影响性能。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑