ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

C++实战:从零复刻经典扫雷游戏,掌握二维数组与递归算法

2026/8/5 23:27:17 拓冰建站 浏览量
C++实战:从零复刻经典扫雷游戏,掌握二维数组与递归算法 1. 项目概述为什么选择用C复刻扫雷扫雷这个几乎预装在每一台Windows电脑上的小游戏是无数人的编程启蒙。它规则简单但背后蕴含的逻辑却相当精妙——随机生成、递归展开、状态判断这些恰恰是理解程序核心思想的绝佳案例。今天我们不用现成的游戏引擎而是回归最纯粹的C从零开始一行代码一行代码地把它“造”出来。这不仅仅是为了怀旧更是一次扎实的编程实战训练。通过这个项目你能亲手触摸到二维数组如何构建游戏地图递归算法如何实现“一键翻开”大片区域随机数如何埋下“惊喜”与“惊吓”以及如何用控制台或简单的图形库来构建交互界面。无论你是刚学完C语法想找个项目练手还是想深入理解游戏循环与状态管理这个“复刻扫雷”的旅程都将让你收获满满。我当年就是靠着实现这些小游戏才真正把书本上的指针、内存管理和算法给“盘活”的。2. 核心设计思路与架构拆解在动手写代码之前我们必须把扫雷游戏拆解成几个核心模块并想清楚它们之间如何协作。一个清晰的架构能避免后期代码变成一团乱麻。2.1 游戏核心数据模型设计扫雷的本质是一个信息不完全的二维棋盘。我们需要两个核心的二维数组来分别存储“底牌”和“桌面”。地雷图MineMap这是游戏的“标准答案”一个ROWS x COLS的二维数组例如int mineMap[ROWS][COLS]。每个格子只有两种状态是雷比如用-1表示或不是雷。如果不是雷则存储一个数字代表其周围8个格子中地雷的数量。显示图DisplayMap这是玩家看到的界面同样大小的二维数组例如char displayMap[ROWS][COLS]。每个格子对玩家来说有几种状态未翻开用*或■表示、已翻开显示数字或空白、已标记为地雷用F或⚑表示。这两个图必须严格对应。当玩家点击一个格子时程序根据mineMap中的信息来更新displayMap。这种“模型-视图”分离的思想在稍复杂的项目中至关重要。注意数组下标从0开始但为了玩家友好我们通常让行和列的编号从1开始显示。在内部处理时要特别注意数组越界问题尤其是在计算周围格子时对边缘格子的特殊处理。2.2 游戏主循环与状态机任何游戏都可以看作一个状态机扫雷也不例外。它的主循环逻辑非常经典初始化Init创建并初始化mineMap和displayMap。在mineMap中随机布置指定数量的地雷并计算所有非雷格子的周围雷数。渲染Render将当前的displayMap以美观的格式打印到控制台。处理输入ProcessInput等待玩家输入。输入通常包括行 列表示翻开行 列 F表示标记/取消标记地雷。更新游戏状态Update这是游戏逻辑的核心。如果输入是翻开操作若点中地雷 - 游戏结束失败显示全部地雷。若点中数字 - 仅翻开该格子。若点中数字为0的格子 - 触发递归展开自动翻开所有相邻的0区域及其边界。如果输入是标记操作切换该格子的标记状态。判断胜负CheckWin检查是否满足胜利条件所有非雷格子均已翻开且所有地雷均被正确标记或保持未翻开但未被错误翻开。若胜利则进入胜利状态。循环只要游戏未结束既未赢也未输就重复步骤2-5。这个初始化 - 渲染 - 输入 - 更新 - 判断 - 渲染...的循环是绝大多数游戏程序的骨架。2.3 关键技术选型控制台 vs. 图形库对于第一个版本我强烈建议从控制台Console开始。使用cout和cin进行输入输出。它的优势是极其简单无需处理复杂的窗口、消息和事件能让你100%专注于游戏逻辑本身。我们可以用字符来绘制棋盘比如用*代表未翻开用数字1-8代表周围雷数用F代表旗子。当你用控制台完美实现了所有逻辑后如果想让游戏更美观可以考虑引入轻量级的图形库例如EasyX仅限Windows或SFML、SDL。这些库能帮你绘制像素化的格子、图片化的地雷和旗子。但请记住这本质上是将你的“渲染”模块从打印字符替换为调用图形API画图游戏的核心数据模型和逻辑循环完全不需要改动。这种“先实现后美化”的思路能有效降低初期复杂度。3. 从零开始的详细实现步骤下面我们进入实战环节我会手把手带你实现一个控制台版本的扫雷。假设我们做一个9x9的初级棋盘埋10颗雷。3.1 环境准备与项目创建首先确保你有一个可用的C开发环境。Visual Studio、VS Code配合MinGW或CLion都可以。我个人习惯用VS Code轻量且配置灵活。创建项目新建一个文件夹例如Minesweeper。在里面创建main.cpp。包含头文件我们将用到输入输出、随机数、时间函数等。#include iostream #include cstdlib // 用于 rand(), srand() #include ctime // 用于 time() #include iomanip // 用于格式化输出 using namespace std;3.2 核心数据结构定义与初始化在全局区域或一个结构体/类中定义游戏所需的常量和数组。// 常量定义 const int ROWS 9; const int COLS 9; const int MINES 10; // 游戏核心数据 int mineMap[ROWS][COLS] {0}; // 地雷图-1表示雷0-8表示周围雷数 char displayMap[ROWS][COLS]; // 显示图*未翻开F标记数字或空格已翻开 bool gameOver false; bool gameWin false;接下来是初始化函数initGame()void initGame() { // 1. 初始化显示图全部为未翻开状态 * for (int i 0; i ROWS; i) { for (int j 0; j COLS; j) { displayMap[i][j] *; } } // 2. 清空地雷图 for (int i 0; i ROWS; i) { for (int j 0; j COLS; j) { mineMap[i][j] 0; } } // 3. 随机布置地雷 srand((unsigned int)time(nullptr)); // 设置随机种子 int minesPlaced 0; while (minesPlaced MINES) { int r rand() % ROWS; int c rand() % COLS; if (mineMap[r][c] ! -1) { // 防止重复放雷 mineMap[r][c] -1; minesPlaced; } } // 4. 计算每个非雷格子周围的雷数 // 方向数组表示周围8个格子的行、列偏移量 int dir[8][2] {{-1,-1}, {-1,0}, {-1,1}, {0,-1}, {0,1}, {1,-1}, {1,0}, {1,1}}; for (int i 0; i ROWS; i) { for (int j 0; j COLS; j) { if (mineMap[i][j] -1) continue; // 是雷则跳过 int count 0; for (int d 0; d 8; d) { int nr i dir[d][0]; int nc j dir[d][1]; // 检查新坐标是否在棋盘范围内 if (nr 0 nr ROWS nc 0 nc COLS) { if (mineMap[nr][nc] -1) count; } } mineMap[i][j] count; // 周围雷数存入地雷图 } } gameOver false; gameWin false; }实操心得srand(time(nullptr))是生成随机数的标准做法确保每次游戏的地雷布局都不同。布置地雷时使用while循环和重复检查虽然对于小棋盘效率足够但理论上当雷数接近格子总数时可能陷入死循环。更严谨的做法是预先生成所有格子坐标的列表打乱顺序然后取前MINES个位置放雷。3.3 递归展开算法的实现这是扫雷游戏的灵魂所在。当玩家点开一个周围雷数为0的格子时需要自动翻开所有相邻的0区域直到被数字格子包围。// 递归展开函数 void expand(int r, int c) { // 递归终止条件越界、已翻开、是雷 if (r 0 || r ROWS || c 0 || c COLS) return; if (displayMap[r][c] ! *) return; // 不是未翻开状态则返回 if (mineMap[r][c] -1) return; // 理论上不会点开雷这里作为安全保护 // 翻开当前格子 if (mineMap[r][c] 0) { displayMap[r][c] 0 mineMap[r][c]; // 将数字转换成字符如 1 - 1 return; // 是数字则翻开它并停止递归 } else { // 是0翻开为空格 displayMap[r][c] ; // 递归展开周围的8个格子 expand(r-1, c-1); expand(r-1, c); expand(r-1, c1); expand(r, c-1); expand(r, c1); expand(r1, c-1); expand(r1, c); expand(r1, c1); } }注意事项递归深度在扫雷中是可接受的因为最坏情况整个棋盘无雷下9x9的棋盘递归深度也不会超过81。但如果你要做超大棋盘比如100x100则需要考虑使用栈Stack来模拟递归过程避免栈溢出。对于初学者递归写法最直观。3.4 游戏渲染与输入处理渲染函数负责把displayMap漂亮地打印出来。我们加上行号和列号方便玩家输入。void render() { system(cls); // Windows清屏命令。Linux/Mac用 system(clear); cout ; for (int j 0; j COLS; j) { cout setw(2) j1 ; // 打印列号 } cout endl; cout ; for (int j 0; j COLS; j) cout ---; cout - endl; for (int i 0; i ROWS; i) { cout setw(2) i1 | ; // 打印行号 for (int j 0; j COLS; j) { cout displayMap[i][j] ; } cout | endl; } cout ; for (int j 0; j COLS; j) cout ---; cout - endl; cout 剩余雷数: MINES | 输入格式: 行 列 [F] (例如: 5 3 或 5 3 F) endl; }输入处理函数需要解析玩家的指令。我们设计为输入两个整数代表行和列如果后面跟了一个字符F或f则表示标记操作。void processInput() { int r, c; char action; cout 请输入操作: ; cin r c; // 转换为数组下标从0开始 r--; c--; // 检查输入是否在有效范围内 if (r 0 || r ROWS || c 0 || c COLS) { cout 输入坐标超出范围 endl; return; } // 检查是否已翻开 if (displayMap[r][c] ! * displayMap[r][c] ! F) { cout 该位置已翻开 endl; return; } // 判断是翻开还是标记 if (cin.peek() F || cin.peek() f) { cin action; // 标记/取消标记逻辑 if (displayMap[r][c] *) { displayMap[r][c] F; } else if (displayMap[r][c] F) { displayMap[r][c] *; } } else { // 翻开逻辑 if (mineMap[r][c] -1) { // 踩到雷了 gameOver true; // 游戏结束显示所有地雷 for (int i 0; i ROWS; i) { for (int j 0; j COLS; j) { if (mineMap[i][j] -1) { displayMap[i][j] ; // 用表示地雷 } } } } else { // 安全翻开 expand(r, c); } } // 清空输入缓冲区 cin.ignore(1024, \n); }3.5 胜负判定与主循环整合胜负判定函数需要遍历整个棋盘。bool checkWin() { for (int i 0; i ROWS; i) { for (int j 0; j COLS; j) { // 情况1地雷没有被正确标记显示为F且没有被翻开 // 情况2非地雷格子没有被翻开 // 这两种情况都说明游戏未赢 if (mineMap[i][j] -1 displayMap[i][j] ! F) { return false; // 有雷没被标记 } if (mineMap[i][j] ! -1 displayMap[i][j] *) { return false; // 有非雷格子没翻开 } } } return true; // 所有雷都被标记且所有非雷都被翻开 }最后将所有模块整合到main函数的主循环中int main() { initGame(); while (!gameOver !gameWin) { render(); processInput(); gameWin checkWin(); // 每次操作后检查是否胜利 } // 游戏结束渲染最终局面 render(); if (gameOver) { cout 很遗憾你踩到地雷了游戏结束。 endl; } else if (gameWin) { cout 恭喜你成功排除了所有地雷 endl; } cout 按任意键退出... endl; cin.get(); return 0; }至此一个功能完整的控制台扫雷游戏就完成了。你可以编译并运行它体验自己亲手创造的游戏。4. 进阶优化与功能扩展基础版本跑通后我们可以从多个维度对它进行升级这能让你学到更多工程化的技巧。4.1 代码重构面向对象设计目前的代码将所有数据和函数都放在全局对于小项目没问题但不利于维护和扩展。我们可以将其封装成一个Game类。class MinesweeperGame { private: int rows, cols, mines; int** mineMap; char** displayMap; bool gameOver, gameWin; void initMaps(); // 初始化数组 void placeMines(); // 布置地雷 void calculateNumbers(); // 计算数字 void expand(int r, int c); // 递归展开 bool isValid(int r, int c); // 检查坐标有效性 public: MinesweeperGame(int r9, int c9, int m10); // 构造函数 ~MinesweeperGame(); // 析构函数记得释放动态内存 void render(); // 渲染 void processInput(); // 处理输入 bool isGameOver() const { return gameOver; } bool isGameWin() const { return gameWin; } void revealAll(); // 游戏结束时显示所有雷 };这样主函数将变得非常简洁int main() { MinesweeperGame game; while (!game.isGameOver() !game.isGameWin()) { game.render(); game.processInput(); } game.revealAll(); game.render(); // ... 输出胜负信息 return 0; }面向对象的设计让数据与操作内聚提高了代码的可读性和可复用性。例如你可以轻松创建多个不同难度的游戏实例。4.2 功能增强添加计时器与难度选择计时器在Game类中添加time_t startTime成员在构造函数或第一次操作时记录开始时间。在render()函数中计算并显示已用时间difftime(current_time, startTime)。难度选择可以在游戏开始前让玩家选择难度初级9x9/10雷中级16x16/40雷高级16x30/99雷。通过改变rows,cols,mines这三个成员变量并重新调用初始化函数来实现。注意切换难度需要动态分配二维数组使用vectorvectorint会更安全方便。首次点击保护一个常见的用户体验优化是确保玩家第一次点击绝对不会是雷。可以在placeMines()函数中将第一次点击的坐标排除在可布雷区域之外。剩余雷数计算在类中添加一个int flagsPlaced成员记录玩家插了多少面旗子。剩余雷数 总雷数 - 已插旗数。在render()中显示这个动态数字。4.3 图形界面迁移使用EasyX入门如果你在Windows上并且想让游戏有真正的图形界面EasyX是一个极佳的选择。它是一个为C设计的简易图形库。迁移步骤大致如下安装EasyX从其官网下载安装包安装后在Visual Studio项目中包含graphics.h头文件即可。修改渲染模块不再使用cout而是用EasyX的绘图函数。initgraph(宽度, 高度)初始化图形窗口。在循环中使用BeginBatchDraw()和EndBatchDraw()进行双缓冲绘图避免闪烁。用setfillcolor(颜色)和fillrectangle(x, y, x格子大小, y格子大小)绘制格子。用outtextxy(x, y, 字符串)在格子上绘制数字或字符。用loadimage()和putimage()来显示地雷、旗子等图片资源。修改输入模块不再使用cin而是使用ExMessage msg;和peekmessage(msg, EX_MOUSE)来获取鼠标消息。通过判断msg.message WM_LBUTTONDOWN左键点击和WM_RBUTTONDOWN右键点击来对应“翻开”和“标记”操作。通过msg.x和msg.y计算点击了哪个格子。踩坑提醒图形编程中坐标计算是容易出错的地方。务必建立清晰的映射关系屏幕像素坐标 (px, py) - 格子索引 (row, col)。公式通常是col px / CELL_SIZE,row py / CELL_SIZE。5. 调试技巧与常见问题实录在开发过程中你肯定会遇到各种Bug。下面是我在实现过程中踩过的坑和解决方法。5.1 数组越界递归展开的“幽灵点击”问题描述在实现expand递归函数时最初我直接递归调用expand(r-1, c)等没有在函数开头检查坐标有效性导致递归时访问了mineMap[-1][0]这样的非法内存程序直接崩溃。解决方案正如我在代码中写的递归函数的第一件事就是进行边界检查。这是递归处理二维网格问题的铁律。5.2 随机数雷区重叠问题描述在while循环中随机放雷如果雷数设置得很大比如在9x9里放80颗雷循环可能会运行很久因为后面随机到的位置很可能已经是雷了。解决方案采用“采样”法。创建一个包含所有格子索引0-80的向量用std::random_shuffle或std::shuffle打乱顺序然后取前MINES个位置放雷。这是标准做法效率是O(N)且绝对均匀。vectorint positions(ROWS * COLS); iota(positions.begin(), positions.end(), 0); // 填充0,1,2,...80 random_shuffle(positions.begin(), positions.end()); for (int i 0; i MINES; i) { int pos positions[i]; int r pos / COLS; int c pos % COLS; mineMap[r][c] -1; }5.3 输入处理不健壮问题描述玩家可能输入非数字字符或者格式错误的指令导致cin进入错误状态程序卡死或行为异常。解决方案使用getline(cin, str)读取整行然后用stringstream进行解析并检查解析是否成功。这是处理用户输入更健壮的方式。string line; getline(cin, line); stringstream ss(line); int r, c; char a; if (ss r c) { // 成功读取两个整数 if (ss a (a F || a f)) { // 是标记操作 } else { // 是翻开操作 } } else { cout 输入格式错误 endl; }5.4 胜负判断逻辑漏洞问题描述最初的胜利条件只检查了“所有非雷格子被翻开”。但玩家可能通过错误标记在非雷格插旗来满足这个条件这是不合理的。解决方案胜利条件必须是且的关系所有地雷格子都被标记为F或保持未翻开但未被错误翻开。所有非地雷格子都被翻开。 必须同时满足这两点代码中的checkWin函数正是这样实现的。5.5 图形界面下的性能与体验问题描述在图形界面版本中如果每帧都全屏重绘所有格子在棋盘较大时可能会感到卡顿。解决方案使用双缓冲这是图形编程的必备技能EasyX的BeginBatchDraw()和EndBatchDraw()就是为此而生。局部更新维护一个“脏矩形”列表。只有当某个格子的状态发生改变时才将其加入列表在渲染时只重绘这些格子。对于扫雷这种格子状态变化不频繁的游戏能极大提升性能。资源管理将地雷、旗子、数字等图片一次性加载到内存中IMAGE对象而不是每次绘制都从文件读取。这个用C复刻扫雷的项目从控制台到图形界面从过程式到面向对象几乎涵盖了初级到中级C程序员需要掌握的大部分核心概念和实战技能。它像一把钥匙帮你打开了游戏开发与软件工程的大门。当你看到自己写的程序能像经典游戏一样运行时那种成就感是无与伦比的。我建议你在完成基础版本后一定要尝试至少一项进阶功能无论是重构为类还是加上图形界面这中间的思考和解决问题的过程比单纯抄写代码有价值得多。