1. 项目概述与核心价值
扫雷,这个几乎刻在每一个Windows用户DNA里的小游戏,远不止是消磨时间的工具。对于C++初学者和图形界面编程的入门者来说,亲手实现一个扫雷游戏,是一次绝佳的综合性实战演练。它麻雀虽小,五脏俱全,涵盖了二维数组、递归算法、随机数生成、鼠标交互、图形绘制、状态管理等多个核心编程概念。而EasyX,作为一个专为C++设计的轻量级图形库,恰好为我们搭建了一座从枯燥的控制台“黑框框”通往生动图形化世界的桥梁。它封装了Windows的GDI绘图接口,提供了诸如circle、rectangle、putpixel、outtextxy等直观易用的函数,让我们无需深入Win32 API的复杂细节,就能快速绘制出游戏界面。
这个项目的目标,就是带你从零开始,用最纯粹的C++语法和EasyX图形库,一步步构建出一个功能完整、交互流畅的扫雷游戏。整个过程,你将不再是被动的代码阅读者,而是从游戏逻辑设计、数据结构定义、到每一行绘图代码、每一个鼠标点击事件处理的主动构建者。最终,你将获得一个可以独立运行的.exe文件,以及一份扎实的、能写进简历里的项目经验。无论你是刚学完C++基础语法想找项目练手的学生,还是希望重温经典算法与图形编程的开发者,这个项目都能提供一条清晰、可执行的路径。
2. 开发环境搭建与工具选型
工欲善其事,必先利其器。一个顺手的开发环境能极大提升编码效率和调试体验。虽然EasyX官方主要支持Visual Studio,但在其他主流IDE上配置也并非难事。这里我会给出两种最主流的选择方案。
2.1 集成开发环境(IDE)选择
方案一:Visual Studio 2022 (社区版)这是与EasyX兼容性最好、配置最简单的方案。VS社区版免费且功能强大,其内置的MSVC编译器与EasyX的静态链接库无缝对接。
- 优势:一键安装EasyX,调试功能强大,智能提示(IntelliSense)优秀。
- 安装:从微软官网下载Visual Studio Installer,在安装时勾选“使用C++的桌面开发”工作负载即可。
- 配置EasyX:访问EasyX官网(easyx.cn),下载安装包。运行安装程序,它会自动检测已安装的Visual Studio版本并进行配置。安装完成后,在VS中新建一个“空项目”或“控制台应用”,在代码中直接
#include <graphics.h>即可使用。
方案二:VSCode + MinGW-w64如果你偏爱轻量级、可高度定制的编辑器,VSCode是绝佳选择。配合MinGW-w64编译器,同样可以运行EasyX程序。
- 优势:轻快、跨平台(Windows下)、插件生态丰富。
- 安装配置:
- 安装MinGW-w64:推荐从 MSYS2 或 WinLibs 获取预编译的GCC工具链。安装后,需将
bin目录(如C:\msys64\mingw64\bin)添加到系统的PATH环境变量中。 - 安装VSCode:从官网下载安装。
- 配置VSCode插件:安装“C/C++”扩展(由Microsoft发布)。
- 配置EasyX:这是关键步骤。EasyX官网提供了针对MinGW的适配库(通常是一个
.a静态库文件和一个修改过的graphics.h头文件)。你需要:- 将下载的
graphics.h等头文件放入MinGW的include目录。 - 将
libeasyx.a等库文件放入MinGW的lib目录。 - 在VSCode中配置项目的
tasks.json(编译任务)和launch.json(调试任务),在编译参数中明确链接libeasyx.a和libgdi32.a、libole32.a等Windows系统库。
- 将下载的
- 安装MinGW-w64:推荐从 MSYS2 或 WinLibs 获取预编译的GCC工具链。安装后,需将
注意:对于新手,强烈推荐方案一(Visual Studio)。它能避免大量环境配置的坑,让你更专注于代码逻辑本身。本教程后续的代码和演示均基于Visual Studio环境。
2.2 项目结构与代码管理
在开始编码前,建立清晰的项目结构是个好习惯。在你的项目目录下,可以这样组织:
Minesweeper/ ├── MineSweeper.sln (Visual Studio解决方案文件) ├── MineSweeper.vcxproj (项目文件) ├── src/ (源代码目录) │ ├── main.cpp (程序入口,主循环) │ ├── game.h (游戏核心逻辑头文件) │ ├── game.cpp (游戏核心逻辑实现) │ ├── graphics.h (图形绘制相关头文件,可能封装EasyX调用) │ └── graphics.cpp (图形绘制实现) ├── resources/ (资源目录,可选) │ └── images/ (存放数字、地雷等图片素材) └── README.md (项目说明文档)对于这样一个单文件或少量文件的小项目,你也可以将所有代码写在一个main.cpp里,这完全可行。但拆分成多个文件(头文件.h和源文件.cpp)有助于逻辑分离,让代码更清晰、更易维护。例如,将游戏状态计算和图形界面渲染分开,符合“模型-视图”分离的思想。
3. 游戏核心逻辑设计与数据结构
在绘制任何图形之前,我们必须先用代码构建出扫雷游戏的“大脑”——即它的数据模型和核心规则。这是整个项目的基石。
3.1 游戏状态与棋盘表示
扫雷棋盘本质上是一个二维网格。我们需要用数据结构来记录每个格子的多种状态。 首先,定义一些游戏常量:
// 游戏难度常量 const int EASY_WIDTH = 9; const int EASY_HEIGHT = 9; const int EASY_MINES = 10; const int MEDIUM_WIDTH = 16; const int MEDIUM_HEIGHT = 16; const int MEDIUM_MINES = 40; const int HARD_WIDTH = 30; const int HARD_HEIGHT = 16; const int HARD_MINES = 99; // 格子状态常量 const int CELL_UNKNOWN = 0; // 未揭开 const int CELL_FLAGGED = 1; // 已标记为旗子 const int CELL_QUESTION = 2; // 标记为问号 const int CELL_REVEALED = 3; // 已揭开 const int CELL_MINE = 9; // 地雷 (用一个特殊值表示,比如9)接下来,设计核心的Game类来管理所有游戏状态:
class Game { private: int width_; // 棋盘宽度(格子数) int height_; // 棋盘高度(格子数) int mineCount_; // 地雷总数 int revealedCount_; // 已揭开的非雷格子数 bool gameOver_; // 游戏是否结束 bool firstClick_; // 是否是第一次点击(用于安全开局) time_t startTime_; // 游戏开始时间 // 核心二维数组:存储每个格子下的真实内容 // -1: 地雷, 0-8: 周围8格中的地雷数 std::vector<std::vector<int>> board_; // 状态二维数组:存储每个格子的当前显示状态 (CELL_UNKNOWN, CELL_FLAGGED等) std::vector<std::vector<int>> state_; // 标记数组:记录玩家标记的旗子位置(用于判断是否标记了非雷格) std::vector<std::vector<bool>> flags_; public: Game(int width, int height, int mineCount); void init(); // 初始化或重置游戏 void placeMines(int safeX, int safeY); // 放置地雷,确保(safeX, safeY)周围无雷 int countAdjacentMines(int x, int y); // 计算(x,y)周围地雷数 bool reveal(int x, int y); // 揭开格子(x,y),返回是否触发地雷 void toggleFlag(int x, int y); // 切换标记(旗子/问号/无) bool isWin() const; // 判断是否获胜 // ... 其他获取游戏状态的方法 };关键设计解析:
- 双棋盘结构:
board_存储“真相”(哪里是雷,周围有几颗雷),state_存储“表象”(玩家看到的状态)。这种分离是游戏逻辑的核心。 - 安全开局:
firstClick_和placeMines函数配合实现。在玩家第一次点击后,再生成地雷,并确保第一次点击的格子及其周围一圈(3x3区域)绝对没有地雷,这是扫雷游戏的通用友好规则。 - 递归揭开:
reveal函数是算法核心。当揭开的格子周围地雷数为0时,需要自动递归揭开其周围所有未揭开的格子,直到遇到数字格或边界为止。这涉及到经典的深度优先搜索(DFS)或广度优先搜索(BFS)算法。
3.2 核心算法实现:递归揭开与胜负判定
reveal函数的实现是游戏逻辑的精华。
bool Game::reveal(int x, int y) { // 边界检查或已揭开/已标记的格子,直接返回 if (x < 0 || x >= width_ || y < 0 || y >= height_) return false; if (state_[y][x] == CELL_REVEALED || state_[y][x] == CELL_FLAGGED) return false; // 如果是第一次点击,先布置地雷(安全开局) if (firstClick_) { placeMines(x, y); firstClick_ = false; startTime_ = time(nullptr); // 记录开始时间 } // 如果踩中地雷,游戏结束 if (board_[y][x] == -1) { state_[y][x] = CELL_REVEALED; gameOver_ = true; return true; // 返回true表示触雷 } // 揭开当前格子 state_[y][x] = CELL_REVEALED; revealedCount_++; // 如果揭开的格子数字为0(周围无雷),则递归揭开周围8个格子 if (countAdjacentMines(x, y) == 0) { // 使用递归DFS for (int dy = -1; dy <= 1; ++dy) { for (int dx = -1; dx <= 1; ++dx) { if (dx == 0 && dy == 0) continue; // 跳过自身 reveal(x + dx, y + dy); // 递归调用 } } // 也可以使用栈或队列进行BFS,避免递归深度过大(对于超大棋盘) } return false; // 未触雷 }胜负判定:
- 失败:
gameOver_标志被设为true(即reveal函数返回true)。 - 胜利:所有非地雷格子都被揭开。即
revealedCount_ == (width_ * height_ - mineCount_)。
4. 基于EasyX的图形界面实现
逻辑层完成后,我们需要为其绘制一个“外壳”。EasyX让这个过程变得直观。
4.1 窗口初始化与绘图常量
首先,我们需要根据棋盘大小和格子尺寸计算窗口大小,并初始化图形窗口。
#include <graphics.h> // EasyX图形库头文件 #include <conio.h> // 用于_getch()等 #include <time.h> // 绘图常量 const int CELL_SIZE = 30; // 每个格子的像素大小 const int BORDER_LEFT = 20; // 左边距 const int BORDER_TOP = 60; // 顶边距(留出空间显示雷数和计时器) const int INFO_AREA_HEIGHT = 40; // 顶部信息区高度 // 颜色定义 const COLORREF COLOR_GRID_LINE = LIGHTGRAY; const COLORREF COLOR_CELL_HIDDEN = RGB(192, 192, 192); // 经典的未揭开格子灰色 const COLORREF COLOR_CELL_REVEALED = WHITE; const COLORREF COLOR_FLAG = RED; const COLORREF COLOR_MINE = BLACK; const COLORREF COLOR_NUM[9] = { // 数字1-8的颜色,0不用显示 BLUE, // 1 RGB(0, 128, 0), // 2 (绿色) RED, // 3 RGB(0, 0, 128), // 4 (深蓝) RGB(128, 0, 0), // 5 (深红) RGB(0, 128, 128), // 6 (青色) PURPLE, // 7 GRAY // 8 }; void initGraphics(int boardWidth, int boardHeight) { int screenWidth = BORDER_LEFT * 2 + boardWidth * CELL_SIZE; int screenHeight = BORDER_TOP + INFO_AREA_HEIGHT + boardHeight * CELL_SIZE; initgraph(screenWidth, screenHeight, SHOWCONSOLE); // SHOWCONSOLE方便调试 setbkcolor(WHITE); cleardevice(); settextstyle(20, 0, _T("宋体")); // 设置字体 settextcolor(BLACK); setlinecolor(COLOR_GRID_LINE); }4.2 绘制游戏界面:棋盘、格子与数字
绘制分为两部分:静态的棋盘网格和动态的格子状态。
void drawCell(int x, int y, int cellState, int cellValue) { // 计算格子左上角在屏幕上的坐标 int screenX = BORDER_LEFT + x * CELL_SIZE; int screenY = BORDER_TOP + y * CELL_SIZE; // 1. 绘制格子背景 if (cellState == CELL_REVEALED) { setfillcolor(COLOR_CELL_REVEALED); } else { setfillcolor(COLOR_CELL_HIDDEN); } fillrectangle(screenX, screenY, screenX + CELL_SIZE - 1, screenY + CELL_SIZE - 1); // 2. 绘制格子边框(凸起或凹陷效果,增强立体感) if (cellState != CELL_REVEALED) { // 未揭开:左上亮,右下暗,模拟凸起按钮 setlinecolor(WHITE); line(screenX, screenY, screenX + CELL_SIZE - 1, screenY); // 上边 line(screenX, screenY, screenX, screenY + CELL_SIZE - 1); // 左边 setlinecolor(DARKGRAY); line(screenX + CELL_SIZE - 1, screenY, screenX + CELL_SIZE - 1, screenY + CELL_SIZE - 1); // 右边 line(screenX, screenY + CELL_SIZE - 1, screenX + CELL_SIZE - 1, screenY + CELL_SIZE - 1); // 下边 } else { // 已揭开:简单的单线边框 setlinecolor(COLOR_GRID_LINE); rectangle(screenX, screenY, screenX + CELL_SIZE, screenY + CELL_SIZE); } // 3. 根据格子状态和内容绘制中心元素 switch (cellState) { case CELL_FLAGGED: // 绘制小红旗(可以用多边形绘制,或使用图片) setfillcolor(COLOR_FLAG); setlinecolor(COLOR_FLAG); // 简化版:画一个红色实心三角形 solidpolygon(...); // 需要计算三角形三个顶点的坐标 break; case CELL_QUESTION: // 绘制蓝色问号 settextcolor(BLUE); outtextxy(screenX + CELL_SIZE/2 - 5, screenY + CELL_SIZE/2 - 10, _T("?")); break; case CELL_REVEALED: if (cellValue == -1) { // 地雷 setfillcolor(COLOR_MINE); setlinecolor(COLOR_MINE); fillcircle(screenX + CELL_SIZE/2, screenY + CELL_SIZE/2, CELL_SIZE/3); } else if (cellValue > 0) { // 数字1-8 TCHAR str[2]; _stprintf_s(str, _T("%d"), cellValue); settextcolor(COLOR_NUM[cellValue - 1]); // 根据数字选择颜色 outtextxy(screenX + CELL_SIZE/2 - 5, screenY + CELL_SIZE/2 - 10, str); } // cellValue == 0 时不绘制任何东西 break; // CELL_UNKNOWN 状态不需要绘制额外内容 } } void drawBoard(const Game& game) { // 清屏并重绘所有格子 cleardevice(); for (int y = 0; y < game.getHeight(); ++y) { for (int x = 0; x < game.getWidth(); ++x) { int state = game.getCellState(x, y); int value = game.getCellValue(x, y); drawCell(x, y, state, value); } } // 绘制顶部信息:剩余雷数、计时器、笑脸按钮等 drawGameInfo(game); }绘制技巧:
- 立体感:通过改变边框线条的颜色(左上白,右下深灰),可以模拟出Windows经典控件那种按下/弹起的立体效果,极大提升UI质感。
- 居中文本:
outtextxy的坐标是文本左上角。为了将单个数字字符居中在格子内,通常需要将格子中心坐标减去字符预估宽度和高度的一半(如CELL_SIZE/2 - 5和CELL_SIZE/2 - 10),这里的5和10是经验值,取决于字体大小。 - 双缓冲:如果发现画面闪烁,可以在
initgraph时使用EW_DBLBUF标志开启双缓冲,并在每帧绘制完成后调用FlushBatchDraw()。
4.3 鼠标交互与事件循环
图形化游戏的核心是事件驱动。我们需要在主循环中不断获取鼠标消息,并转换为游戏逻辑的输入。
void gameLoop() { Game game(EASY_WIDTH, EASY_HEIGHT, EASY_MINES); initGraphics(game.getWidth(), game.getHeight()); ExMessage msg; // EasyX提供的消息结构体 bool running = true; while (running) { // 1. 处理消息队列 while (peekmessage(&msg, EX_MOUSE)) { // 只处理鼠标消息 if (msg.message == WM_LBUTTONDOWN) { // 将屏幕坐标转换为棋盘格子坐标 int gridX = (msg.x - BORDER_LEFT) / CELL_SIZE; int gridY = (msg.y - BORDER_TOP) / CELL_SIZE; // 检查点击是否在有效棋盘区域内 if (gridX >= 0 && gridX < game.getWidth() && gridY >= 0 && gridY < game.getHeight()) { bool hitMine = game.reveal(gridX, gridY); if (hitMine) { // 游戏结束,显示所有地雷 game.revealAllMines(); drawBoard(game); // 显示失败提示 outtextxy(..., _T("游戏结束!你踩到雷了!")); running = false; } else if (game.isWin()) { drawBoard(game); // 显示胜利提示 outtextxy(..., _T("恭喜你,胜利了!")); running = false; } } else { // 检查是否点击了“重新开始”按钮(笑脸) // ... 处理按钮点击 } } else if (msg.message == WM_RBUTTONDOWN) { int gridX = (msg.x - BORDER_LEFT) / CELL_SIZE; int gridY = (msg.y - BORDER_TOP) / CELL_SIZE; if (gridX >= 0 && gridX < game.getWidth() && gridY >= 0 && gridY < game.getHeight()) { game.toggleFlag(gridX, gridY); // 右键标记 } } } // 2. 更新游戏状态(如计时器) updateTimer(game); // 3. 重绘界面 if (!game.isGameOver()) { drawBoard(game); } // 4. 短暂延迟,避免CPU占用率100% Sleep(10); // 单位:毫秒 } // 游戏结束,等待用户按键退出 _getch(); closegraph(); }交互细节:
- 坐标转换:鼠标消息
msg.x和msg.y是屏幕像素坐标,必须通过减掉边距、除以格子大小并取整,才能得到棋盘上的(gridX, gridY)索引。 - 消息处理顺序:使用
peekmessage而不是getmessage,可以避免阻塞,实现流畅的实时循环。EX_MOUSE过滤器只获取鼠标消息,提高效率。 - 右键标记:扫雷的经典操作是左键揭开,右键循环标记(无标记->旗子->问号->无标记)。
toggleFlag函数需要在Game类中实现此状态循环。
5. 功能增强与性能优化
一个基础版本完成后,我们可以从用户体验和代码质量角度进行增强。
5.1 添加游戏状态显示与交互控件
顶部信息栏是游戏UI的重要组成部分。
void drawGameInfo(const Game& game) { // 1. 绘制剩余雷数 int remainingFlags = game.getMineCount() - game.getFlagCount(); TCHAR mineStr[20]; _stprintf_s(mineStr, _T("雷数: %03d"), remainingFlags); outtextxy(20, 20, mineStr); // 2. 绘制计时器 time_t currentTime = game.isGameStarted() ? time(nullptr) : game.getStartTime(); int elapsedSeconds = (currentTime - game.getStartTime()); TCHAR timeStr[20]; _stprintf_s(timeStr, _T("时间: %03d"), elapsedSeconds); outtextxy(200, 20, timeStr); // 3. 绘制“重新开始”按钮(笑脸) int btnX = (getwidth() - 30) / 2; // 窗口中央 int btnY = 15; setfillcolor(YELLOW); fillcircle(btnX + 15, btnY + 15, 15); // 绘制笑脸表情(根据游戏状态改变:正常/胜利/失败) if (game.isGameOver()) { if (game.isWin()) { // 画胜利的笑脸 setlinecolor(BLACK); arc(btnX + 8, btnY + 10, btnX + 22, btnY + 20, 0, PI); // 上扬的嘴 } else { // 画失败的哭脸 setlinecolor(BLACK); arc(btnX + 8, btnY + 20, btnX + 22, btnY + 30, PI, 2 * PI); // 下垂的嘴 } } else { // 画普通的笑脸 setlinecolor(BLACK); arc(btnX + 8, btnY + 10, btnX + 22, btnY + 20, 0, PI); } // 眼睛 setfillcolor(BLACK); solidcircle(btnX + 10, btnY + 10, 2); solidcircle(btnX + 20, btnY + 10, 2); }在鼠标事件处理中,需要增加对笑脸按钮点击的检测,点击后调用game.init()重置游戏。
5.2 实现“双击数字”快速揭开
这是扫雷的高级技巧:当一个已揭开的数字格周围,玩家标记的旗子数量等于该数字时,双击该数字格可以自动揭开周围所有未标记的格子。这能极大提升游戏效率。
void Game::chord(int x, int y) { // 只有已揭开的数字格才能被“和弦” if (state_[y][x] != CELL_REVEALED || board_[y][x] <= 0) return; int flagCount = 0; // 计算周围8格中标记为旗子的数量 for (int dy = -1; dy <= 1; ++dy) { for (int dx = -1; dx <= 1; ++dx) { if (dx == 0 && dy == 0) continue; int nx = x + dx, ny = y + dy; if (nx >= 0 && nx < width_ && ny >= 0 && ny < height_) { if (state_[ny][nx] == CELL_FLAGGED) flagCount++; } } } // 如果旗子数等于格子数字,则揭开周围未标记且未揭开的格子 if (flagCount == board_[y][x]) { for (int dy = -1; dy <= 1; ++dy) { for (int dx = -1; dx <= 1; ++dx) { if (dx == 0 && dy == 0) continue; int nx = x + dx, ny = y + dy; if (nx >= 0 && nx < width_ && ny >= 0 && ny < height_) { if (state_[ny][nx] == CELL_UNKNOWN || state_[ny][nx] == CELL_QUESTION) { reveal(nx, ny); // 可能触发地雷! } } } } } }在主循环的鼠标事件处理中,需要监听WM_LBUTTONDBLCLK(双击)消息,并在确认点击的是已揭开的数字格后,调用game.chord(gridX, gridY)。
5.3 性能优化与代码健壮性
- 避免全局变量:所有游戏状态和图形设置都应封装在类(
Game,Renderer)中。这提高了代码的模块化和可测试性。 - 局部重绘:上述
drawBoard函数每次重绘整个棋盘,在格子多时可能效率不高。可以优化为“脏矩形”更新,只重绘状态发生改变的格子。但对于扫雷这种格子数量有限(最多30*16=480格)的游戏,全屏重绘在60FPS下也完全不是问题。 - 资源管理:如果使用了图片素材(如更精致的旗子、地雷图案),需要使用EasyX的
IMAGE对象和loadimage函数加载。务必在程序退出前用destroyimage释放。 - 输入验证:所有从鼠标坐标转换来的
gridX、gridY都必须进行边界检查,防止数组越界访问,这是程序崩溃的常见原因。 - 随机数质量:地雷的随机布置应使用
<random>库中的现代随机数发生器,如std::mt19937,而非古老的rand()和srand(time(nullptr)),以保证更好的随机性分布。#include <random> std::random_device rd; std::mt19937 gen(rd()); std::uniform_int_distribution<> distrib(0, width_ * height_ - 1);
6. 常见问题排查与调试技巧
在开发过程中,你几乎一定会遇到下面这些问题。
6.1 编译与链接问题
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
编译错误:无法打开源文件 “graphics.h” | EasyX头文件未正确包含或路径不对。 | 在VS中,确保已通过安装程序安装EasyX。如果是手动配置,检查项目属性 -> C/C++ -> 常规 -> 附加包含目录。 |
链接错误:无法解析的外部符号 __imp_xxx | 缺少链接库,通常是Windows的GDI相关库。 | 在VS项目属性 -> 链接器 -> 输入 -> 附加依赖项中,添加gdi32.lib和ole32.lib。EasyX安装器通常会自动配置。 |
| 程序一闪而过 | 通常是控制台程序,执行完main函数就退出了。 | 在main函数结尾或图形窗口循环后,添加_getch();或system(“pause”);等待按键。或者使用initgraph时不显示控制台(SHOWCONSOLE)。 |
| 在VSCode+MinGW中链接失败 | 未正确链接EasyX for MinGW的静态库或系统库。 | 在tasks.json的args中确保有-leasyx -lgdi32 -lole32等参数,并且库文件路径正确。 |
6.2 运行时逻辑错误
| 问题现象 | 调试思路 | 解决方案 |
|---|---|---|
| 鼠标点击位置错位 | 屏幕坐标到格子坐标的转换公式错误。 | 在drawCell和鼠标事件处理中打印(msg.x, msg.y)和计算后的(gridX, gridY),检查边距BORDER_LEFT、BORDER_TOP和CELL_SIZE是否正确。 |
| 递归揭开时程序卡死或栈溢出 | 递归函数reveal没有终止条件或条件错误,导致无限递归。 | 1. 确保递归前检查格子状态,已揭开的格子不再递归。2. 确保递归只向周围8个方向进行,不会重复访问自身。3. 对于极大棋盘,考虑将递归改为显式的栈(stack)或队列(queue)实现的BFS。 |
| 地雷生成在第一次点击位置 | placeMines函数在第一次点击前就被调用。 | 确保在Game类构造函数或init()中只初始化数组,不布置地雷。地雷布置应在firstClick_为true时,在reveal函数内部首次点击后执行。 |
| 胜利条件判断错误 | revealedCount_计数不准确。 | 检查revealedCount_是否只在成功揭开非雷格时增加(reveal函数中,在确认不是地雷且状态变为CELL_REVEALED后)。标记/取消标记操作不应影响此计数。 |
| 画面闪烁严重 | 直接绘图到屏幕导致。 | 启用双缓冲。`initgraph(width, height, SHOWCONSOLE |
6.3 调试心得与技巧
- 善用控制台输出:即使是在图形界面程序,
SHOWCONSOLE模式下的控制台也是强大的调试工具。在关键逻辑处使用printf或std::cout打印变量状态(如board_和state_的值),可以快速定位逻辑错误。 - 可视化调试:在开发初期,可以修改
drawCell函数,即使格子未揭开,也用小字在角落绘制出board_中的真实值(地雷或数字)。这让你一目了然地看到游戏内部状态,方便验证地雷布置、数字计算是否正确。 - 单元测试思维:将游戏逻辑(
Game类)与图形界面分离的好处之一是,你可以为Game类编写简单的测试。例如,创建一个固定布局的棋盘,然后调用reveal,检查state_变化是否符合预期。 - 边界情况测试:专门测试角落和边缘的格子(周围不是8个邻居)、第一次点击在角落、标记所有地雷后双击数字等边界情况。
走到这里,你已经拥有了一个完全由自己掌控的扫雷游戏。从抽象的数据结构设计,到具体的像素绘制,再到细微的交互体验,每一个环节都浸透着你的思考与代码。这个项目最大的价值不在于复刻了一个经典游戏,而在于你完整地走通了一个小型软件项目的生命周期:需求分析、技术选型、架构设计、编码实现、调试测试。你会发现,很多在课本上孤立的概念,如数组、递归、类、消息循环,在这里自然地串联起来,成为了解决实际问题的工具。当你未来面对更复杂的项目时,这次从零构建扫雷的经验——如何分层、如何设计状态、如何处理交互、如何调试——将成为你技术工具箱里最趁手的基础模型之一。