三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

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

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 游戏主循环与状态机

任何游戏都可以看作一个状态机,扫雷也不例外。它的主循环逻辑非常经典:

  1. 初始化(Init):创建并初始化mineMapdisplayMap。在mineMap中随机布置指定数量的地雷,并计算所有非雷格子的周围雷数。
  2. 渲染(Render):将当前的displayMap以美观的格式打印到控制台。
  3. 处理输入(ProcessInput):等待玩家输入。输入通常包括:行 列表示翻开,行 列 F表示标记/取消标记地雷。
  4. 更新游戏状态(Update):这是游戏逻辑的核心。
    • 如果输入是翻开操作:
      • 若点中地雷 -> 游戏结束(失败),显示全部地雷。
      • 若点中数字 -> 仅翻开该格子。
      • 若点中数字为0的格子 -> 触发递归展开,自动翻开所有相邻的0区域及其边界。
    • 如果输入是标记操作:切换该格子的标记状态。
  5. 判断胜负(CheckWin):检查是否满足胜利条件:所有非雷格子均已翻开,且所有地雷均被正确标记(或保持未翻开但未被错误翻开)。若胜利,则进入胜利状态。
  6. 循环:只要游戏未结束(既未赢也未输),就重复步骤2-5。

这个初始化 -> 渲染 -> 输入 -> 更新 -> 判断 -> 渲染...的循环,是绝大多数游戏程序的骨架。

2.3 关键技术选型:控制台 vs. 图形库

对于第一个版本,我强烈建议从控制台(Console)开始。使用coutcin进行输入输出。它的优势是极其简单,无需处理复杂的窗口、消息和事件,能让你100%专注于游戏逻辑本身。我们可以用字符来绘制棋盘,比如用*代表未翻开,用数字1-8代表周围雷数,用F代表旗子。

当你用控制台完美实现了所有逻辑后,如果想让游戏更美观,可以考虑引入轻量级的图形库,例如EasyX(仅限Windows)或SFMLSDL。这些库能帮你绘制像素化的格子、图片化的地雷和旗子。但请记住,这本质上是将你的“渲染”模块从打印字符替换为调用图形API画图,游戏的核心数据模型和逻辑循环完全不需要改动。这种“先实现后美化”的思路,能有效降低初期复杂度。

3. 从零开始的详细实现步骤

下面,我们进入实战环节,我会手把手带你实现一个控制台版本的扫雷。假设我们做一个9x9的初级棋盘,埋10颗雷。

3.1 环境准备与项目创建

首先,确保你有一个可用的C++开发环境。Visual StudioVS Code(配合MinGW)或CLion都可以。我个人习惯用VS Code,轻量且配置灵活。

  1. 创建项目:新建一个文件夹,例如Minesweeper。在里面创建main.cpp
  2. 包含头文件:我们将用到输入输出、随机数、时间函数等。
    #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, c+1); expand(r, c-1); expand(r, c+1); expand(r+1, c-1); expand(r+1, c); expand(r+1, c+1); } }

注意事项:递归深度在扫雷中是可接受的,因为最坏情况(整个棋盘无雷)下,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) << j+1 << " "; // 打印列号 } cout << endl; cout << " +"; for (int j = 0; j < COLS; ++j) cout << "---"; cout << "-+" << endl; for (int i = 0; i < ROWS; ++i) { cout << setw(2) << i+1 << "| "; // 打印行号 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; }

输入处理函数需要解析玩家的指令。我们设计为:输入两个整数代表行和列,如果后面跟了一个字符Ff,则表示标记操作。

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 r=9, int c=9, int m=10); // 构造函数 ~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 功能增强:添加计时器与难度选择

  1. 计时器:在Game类中添加time_t startTime成员,在构造函数或第一次操作时记录开始时间。在render()函数中计算并显示已用时间difftime(current_time, startTime)
  2. 难度选择:可以在游戏开始前让玩家选择难度(初级9x9/10雷,中级16x16/40雷,高级16x30/99雷)。通过改变rows,cols,mines这三个成员变量,并重新调用初始化函数来实现。注意,切换难度需要动态分配二维数组(使用vector<vector<int>>会更安全方便)。
  3. 首次点击保护:一个常见的用户体验优化是,确保玩家第一次点击绝对不会是雷。可以在placeMines()函数中,将第一次点击的坐标排除在可布雷区域之外。
  4. 剩余雷数计算:在类中添加一个int flagsPlaced成员,记录玩家插了多少面旗子。剩余雷数 = 总雷数 - 已插旗数。在render()中显示这个动态数字。

4.3 图形界面迁移:使用EasyX入门

如果你在Windows上,并且想让游戏有真正的图形界面,EasyX是一个极佳的选择。它是一个为C++设计的简易图形库。

迁移步骤大致如下:

  1. 安装EasyX:从其官网下载安装包,安装后,在Visual Studio项目中包含<graphics.h>头文件即可。
  2. 修改渲染模块:不再使用cout,而是用EasyX的绘图函数。
    • initgraph(宽度, 高度)初始化图形窗口。
    • 在循环中,使用BeginBatchDraw()EndBatchDraw()进行双缓冲绘图,避免闪烁。
    • setfillcolor(颜色)fillrectangle(x, y, x+格子大小, y+格子大小)绘制格子。
    • outtextxy(x, y, 字符串)在格子上绘制数字或字符。
    • loadimage()putimage()来显示地雷、旗子等图片资源。
  3. 修改输入模块:不再使用cin,而是使用ExMessage msg;peekmessage(&msg, EX_MOUSE)来获取鼠标消息。通过判断msg.message == WM_LBUTTONDOWN(左键点击)和WM_RBUTTONDOWN(右键点击)来对应“翻开”和“标记”操作。通过msg.xmsg.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_shufflestd::shuffle打乱顺序,然后取前MINES个位置放雷。这是标准做法,效率是O(N),且绝对均匀。

vector<int> 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 胜负判断逻辑漏洞

问题描述:最初的胜利条件只检查了“所有非雷格子被翻开”。但玩家可能通过错误标记(在非雷格插旗)来满足这个条件,这是不合理的。

解决方案:胜利条件必须是的关系:

  1. 所有地雷格子都被标记为F(或保持未翻开但未被错误翻开)。
  2. 所有非地雷格子都被翻开。 必须同时满足这两点,代码中的checkWin函数正是这样实现的。

5.5 图形界面下的性能与体验

问题描述:在图形界面版本中,如果每帧都全屏重绘所有格子,在棋盘较大时可能会感到卡顿。

解决方案

  1. 使用双缓冲:这是图形编程的必备技能,EasyX的BeginBatchDraw()EndBatchDraw()就是为此而生。
  2. 局部更新:维护一个“脏矩形”列表。只有当某个格子的状态发生改变时,才将其加入列表,在渲染时只重绘这些格子。对于扫雷这种格子状态变化不频繁的游戏,能极大提升性能。
  3. 资源管理:将地雷、旗子、数字等图片一次性加载到内存中(IMAGE对象),而不是每次绘制都从文件读取。

这个用C++复刻扫雷的项目,从控制台到图形界面,从过程式到面向对象,几乎涵盖了初级到中级C++程序员需要掌握的大部分核心概念和实战技能。它像一把钥匙,帮你打开了游戏开发与软件工程的大门。当你看到自己写的程序能像经典游戏一样运行时,那种成就感是无与伦比的。我建议你在完成基础版本后,一定要尝试至少一项进阶功能,无论是重构为类,还是加上图形界面,这中间的思考和解决问题的过程,比单纯抄写代码有价值得多。

← 返回列表