C++扫雷小游戏全解析:从二维数组到递归展开的练手项目

发布时间:2026/9/13 13:06:48
C++扫雷小游戏全解析:从二维数组到递归展开的练手项目 如果你学过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() { vectorpairint, 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)三是从C11开始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_castchar(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 环境准备与初始工程结构这个项目对环境要求非常宽容只要能编译C11以上标准的工具都行。我自己平时常用VS Code MinGW组合命令大概是g minesweeper.cpp -o minesweeper -stdc17Windows用户用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_device和mt19937它们不需要手动设置种子质量也更好。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) { vectorpairint, 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_castchar(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]是C17的结构化绑定语法如果你的编译器不支持C17可以用pairint,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类把棋盘、常量、所有操作函数收进类内部。关键改动思路成员变量改成vectorvectorint mapBoard和vectorvectorchar 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大法在开发中永远是最管用的武器。祝你们都能写出自己的第一个小游戏。