首页 / 资讯中心 / 文章详情

MFC迷宫游戏开发详解:从递归回溯生成到双缓冲绘制与键盘交互

MFC迷宫游戏开发详解:从递归回溯生成到双缓冲绘制与键盘交互 ★ FEATURED ARTICLE
简介一份基于MFC实现的迷宫游戏项目资料包面向正在学习Windows程序设计、C面向对象和基础游戏开发的初学者用来理解MFC窗口框架与迷宫搜索算法的落地写法。包内共45个文件大小2.13MB包含7个h头文件与6个cpp源文件另有bmp/png界面资源、图标、工程配置文件和可执行exe。可直接运行exe查看效果也能用配套工程重新编译调试。已有378人学习下载。压缩包附ReadMe和游戏思路文档并提供迷宫修改步骤截图、player类实现等方便对照学习文档视图结构、消息映射、图形绘制等关键知识点。游戏核心涉及二维数组迷宫建模、DFS/BFS路径搜索、状态记录与键盘交互代码中均有对应实现内含从工程建立到迷宫自定义的步骤截图适合动手拆解和改造。整体目录清晰既能作为课程设计参考也能帮助读者把MFC界面与路径搜索逻辑串起来。1. 一个MFC迷宫游戏从“画格子”到“可玩的寻路Demo”如果你翻到过一个用 MFC 写的迷宫游戏源码大概率会有两种反应要么觉得“这年头谁还用 MFC”要么就是打开工程后不知道从哪个文件开始看。我拆过这样一个小项目它的核心是一个对话框程序里面用二维数组存迷宫地图用递归回溯生成迷宫再用 OnPaint 把墙和路径画出来最后靠 OnKeyDown 响应方向键控制玩家移动。虽然界面朴素但麻雀虽小五脏俱全。适合刚学完 C 想看看 Windows 窗口程序怎么处理输入输出的人也适合想快速把算法可视化的人——它让你在 MFC 里同时练习了数据建模、绘图、消息映射三件事。这篇文章会把它的工程结构、核心代码、运行方式和改法全部拆开讲。2. 项目结构剖析MFC对话框程序如何组织迷宫核心逻辑2.1 目录结构与关键文件职责这个项目不是单文件硬怼而是标准的 MFC 对话框应用程序结构。创建一个基于对话框的工程后核心文件长这样MazeGame/ ├── MazeGame.sln ├── MazeGame/ │ ├── MazeGame.h // CWinApp 派生类声明 │ ├── MazeGame.cpp // 应用入口初始化 CWinApp │ ├── MazeGameDlg.h // 主对话框类 CMazeGameDlg 声明 │ ├── MazeGameDlg.cpp // 迷宫生成、绘制、按键处理全在这 │ ├── Resource.h // 资源 ID 定义 │ ├── res/ │ │ └── MazeGame.rc2 // 资源脚本 │ └── res/MazeGame.ico // 图标MazeGameDlg.cpp 是绝对的主角迷宫的数据、算法、绘制、交互全在这个文件里加起来大概三四百行。MazeGame.cpp 只是标准模板负责创建一个 CMazeGameDlg 实例并进入消息循环。也就是说你要读懂这个项目百分之八十的时间都在看 MazeGameDlg.cpp。为什么 MFC 项目喜欢把所有东西堆在 Dlg 文件里因为对话框程序的消息响应天然集中OnInitDialog 负责初始化OnPaint 负责绘制OnKeyDown 负责按键逻辑上是一条直线不像文档视图结构还要拆视图和文档类。对小游戏来说这种“一切从对话框出发”的写法反而是最直接的。2.2 MFC的CWinApp、CDialogEx与视图分离思路这个项目用到了两个核心 MFC 类CWinApp 和 CDialogEx。CWinApp 是整个应用的“总开关”它的 InitInstance 函数里写下这样一段BOOL CMazeGameApp::InitInstance() { CWinApp::InitInstance(); CMazeGameDlg dlg; m_pMainWnd dlg; dlg.DoModal(); // 弹出对话框进入模态循环 return FALSE; }DoModal 一调用程序主窗口就是迷宫对话框关闭对话框等于退出进程。模态对话框的好处是不用自己写窗口过程MFC 把 WM_PAINT、WM_KEYDOWN 这些消息封装成虚函数你只需要重写对应的成员函数。CDialogEx 是对话框基类OnInitDialog 在这里被调用。你可以在里面做地图初始化、设置窗口标题、限制窗口大小。需要注意对话框程序默认焦点在一个按钮或编辑框上方向键消息可能被控件吞掉。这个项目里通常会在 OnInitDialog 的最后调用SetFocus()或者把焦点设回对话框本身否则后面按键根本进不到 OnKeyDown这是一个很典型的 MFC 交互坑后面避坑章节会细说。视图分离的思路在这里并不明显但你可以手动拆把迷宫生成算法放到一个独立的 MazeGenerator 类把绘制逻辑放到一个自定义控件 CStatic 派生类对话框只做消息转发。不过原作者没有这样做全部平铺在 Dlg 里。优点是看代码时上下文连续缺点是想复用算法时得复制粘贴。所以我建议你在拿到这份源码后先不要急着重构先把它的流程跑通再考虑拆类。2.3 迷宫数据模型二维数组与墙体表示迷宫的地图数据是这个项目的“心脏”。它用了一个二维数组来表示每个格子常见做法是每个格子记录四面墙是否存在。先看定义#define ROWS 15 #define COLS 15 // 每个格子的墙标志上、右、下、左 bool maze[ROWS][COLS][4]; // 格子访问标记生成迷宫时用 bool visited[ROWS][COLS]; // 玩家坐标 int playerX, playerY;maze[x][y][0] 表示 (x,y) 的上墙[1] 是右墙[2] 是下墙[3] 是左墙。值为 true 表示有墙false 表示通路。这种表示法非常直观画墙的时候只需要遍历每个格子判断相邻格子之间是否有墙即可。另一种做法是用二进制位表示墙比如 0x01 代表上墙、0x02 代表右墙等这样占内存小但可读性差。这份源码用的是 bool 四维数组牺牲了一点空间换来清晰的逻辑。玩家坐标也直接存成两个 int初始位置是 (0,0)终点是 (ROWS-1, COLS-1)。移动时只要修改 playerX 和 playerY然后调用 Invalidate() 触发重绘不需要维护复杂的精灵对象。这种“坐标即状态”的做法在简单游戏里非常实用别一上来就想封装成类先能跑再抽象。3. 核心模块拆解迷宫生成、绘制与玩家移动的完整链路3.1 迷宫生成算法递归回溯的代码实现这个项目生成迷宫用的是递归回溯也就是深度优先搜索的变种。算法核心思想是从起点格子出发随机选择一个未访问的相邻格子打通它们之间的墙然后递归进入那个格子如果当前格子四周都访问过就回退到上一个格子。最终每个格子都能被访问到而且任意两个格子之间只有一条通路。下面这段是典型实现// 方向偏移上、右、下、左 int dx[4] {0, 1, 0, -1}; int dy[4] {-1, 0, 1, 0}; // 递归生成迷宫 void CMazeGameDlg::GenerateMaze(int x, int y) { visited[x][y] true; // 随机打乱四个方向保证迷宫形状不固定 int order[4] {0, 1, 2, 3}; for (int i 3; i 0; i--) { int j rand() % (i 1); std::swap(order[i], order[j]); } for (int i 0; i 4; i) { int dir order[i]; int nx x dx[dir]; int ny y dy[dir]; // 必须在地图范围内且未访问 if (nx 0 nx ROWS ny 0 ny COLS !visited[nx][ny]) { // 打通当前格子和下一个格子之间的墙 maze[x][y][dir] false; maze[nx][ny][(dir 2) % 4] false; // 反向墙也打通 GenerateMaze(nx, ny); } } }参数 x, y 是当前格子坐标。order 数组随机打乱后每次选择的探索方向都不同所以每次生成的迷宫都不一样。关键的打通墙逻辑是当前格子朝 dir 方向的墙设为 false同时下一个格子朝向当前格子的反向墙也设为 false这样两个格子之间就完全畅通了。递归回溯的缺点是深度太深时可能栈溢出对于 15×15 的迷宫来说完全没有问题。如果你想生成更大的迷宫可以把递归改成显式栈。另外 rand() 的随机性比较弱正式做项目可以用std::mt19937替代但这份源码教学性质更强用 rand() 反而更容易理解。生成完毕后记得手动把起点和终点的外墙或者入口位置处理一下比如把左上角和右下角的边界墙打开这样玩家才能从窗口外面走进来。3.2 OnPaint绘制双缓冲解决闪烁绘制迷宫用的是 OnPaint 消息处理函数MFC 默认会创建 CPaintDC。直接画会导致游戏窗口在每次移动时闪得厉害因为背景不断被擦除重画。常见的解决方式是双缓冲先在内存中画好整幅图再一次 BitBlt 到屏幕上。这份代码里也用了这个技巧。void CMazeGameDlg::OnPaint() { CPaintDC dc(this); // 设备上下文 // 创建内存画布防止闪烁 CDC memDC; memDC.CreateCompatibleDC(dc); CBitmap bmp; bmp.CreateCompatibleBitmap(dc, clientWidth, clientHeight); CBitmap* pOldBmp memDC.SelectObject(bmp); // 清空背景 CBrush bgBrush(RGB(240, 240, 240)); memDC.FillRect(CRect(0, 0, clientWidth, clientHeight), bgBrush); int cellW clientWidth / COLS; int cellH clientHeight / ROWS; // 画墙 for (int x 0; x ROWS; x) { for (int y 0; y COLS; y) { CPen pen(PS_SOLID, 2, RGB(0, 0, 0)); memDC.SelectObject(pen); // 上墙 if (maze[x][y][0]) { memDC.MoveTo(x * cellW, y * cellH); memDC.LineTo((x 1) * cellW, y * cellH); } // 右墙 if (maze[x][y][1]) { memDC.MoveTo((x 1) * cellW, y * cellH); memDC.LineTo((x 1) * cellW, (y 1) * cellH); } // 下墙 if (maze[x][y][2]) { memDC.MoveTo(x * cellW, (y 1) * cellH); memDC.LineTo((x 1) * cellW, (y 1) * cellH); } // 左墙 if (maze[x][y][3]) { memDC.MoveTo(x * cellW, y * cellH); memDC.LineTo(x * cellW, (y 1) * cellH); } } } // 画玩家一个蓝色方块 CBrush playerBrush(RGB(50, 100, 200)); memDC.SelectObject(playerBrush); memDC.Rectangle(playerX * cellW cellW/4, playerY * cellH cellH/4, (playerX 1) * cellW - cellW/4, (playerY 1) * cellH - cellH/4); // 画终点红色方块 CBrush endBrush(RGB(200, 50, 50)); memDC.SelectObject(endBrush); memDC.Rectangle((ROWS-1) * cellW cellW/4, (COLS-1) * cellH cellH/4, ROWS * cellW - cellW/4, COLS * cellH - cellH/4); // 一次性拷贝到屏幕 dc.BitBlt(0, 0, clientWidth, clientHeight, memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBmp); bmp.DeleteObject(); }代码里的 clientWidth 和 clientHeight 是在 OnInitDialog 里计算的客户区尺寸cellW 和 cellH 是每个格子的像素宽高。画墙的逻辑是从每个格子的四个边向外画线但这样画会导致相邻格子的公共墙被画两次视觉效果上线条会更粗不过不影响判断。更优雅的做法是只画右墙和下墙然后额外画一圈外边框这样墙就不会重复描边。双缓冲的关键是 CreateCompatibleDC 和 CreateCompatibleBitmap内存 DC 与屏幕 DC 兼容后所有绘制都在内存里完成最后 BitBlt 一次提交。实际跑起来你会发现即使玩家每帧都在移动窗口也不会闪烁。这个技巧在 MFC 游戏里是保命级别的值得单独抄下来。3.3 键盘响应与碰撞检测OnKeyDown里的方向处理玩家操控用的是 OnKeyDown 消息当对话框拥有焦点时按下方向键就会触发。处理逻辑是先算出目标坐标再检查目标格子与当前格子之间有没有墙没有墙才允许移动。下面是核心代码void CMazeGameDlg::OnKeyDown(UINT nChar, UINT nRepCnt, UINT nFlags) { int newX playerX; int newY playerY; switch (nChar) { case VK_UP: newY playerY - 1; if (!maze[playerX][playerY][0]) // 上墙没有阻挡 { playerY newY; } break; case VK_DOWN: newY playerY 1; if (!maze[playerX][playerY][2]) // 下墙没有阻挡 { playerY newY; } break; case VK_LEFT: newX playerX - 1; if (!maze[playerX][playerY][3]) // 左墙没有阻挡 { playerX newX; } break; case VK_RIGHT: newX playerX 1; if (!maze[playerX][playerY][1]) // 右墙没有阻挡 { playerX newX; } break; default: break; } // 检查是否到达终点 if (playerX ROWS - 1 playerY COLS - 1) { AfxMessageBox(_T(恭喜通关)); // 重新生成迷宫并重置玩家位置 ResetGame(); } Invalidate(); // 触发重绘 CDialogEx::OnKeyDown(nChar, nRepCnt, nFlags); }注意这里有一个很容易犯的错先调用 Invalidate 再调用基类的 OnKeyDown。如果你先让基类处理消息可能会被默认逻辑吞掉或者产生光标闪烁所以我们自己处理完立即重绘。newX 和 newY 其实没有真正用上因为判断墙之后直接修改了 playerX/playerY这个变量属于冗余代码新手经常写这种“明明可以直接改偏要算个临时变量”的代码无伤大雅。还有一个隐藏问题如果玩家站在边界并且方向上是外墙比如 x0 时按左键maze[0][y][3] 是 true所以不会越界。但生成迷宫时并没有特意把外圈墙全部置为 true如果你的初始化代码里忘了给外圈赋值玩家就会走出地图。所以初始化时最好把所有墙先置为 true然后再生成迷宫打通内部墙这样边界墙天然是 true就不会越界了。4. 运行与调试从VS编译到键盘控制的完整上手步骤4.1 环境准备与编译配置这个项目是 MFC 对话框程序最适合用 Visual Studio 打开。我用的是 VS2019配置为“Debug - x86”或“Release - x86”。拿到源码后不要直接双击 .sln先检查后缀版本。如果是老版本的 .dsw/.dsp需要让 VS 做一次转换转换过程中可能弹出“是否升级”提示选确定就行。打开工程后按 CtrlShiftB 编译。常见错误集中在两点一是 MFC 头文件找不到需要在项目属性 → 常规 → MFC 的使用 里选“在静态库中使用 MFC”二是字符集不匹配老项目默认使用多字节字符集而新 VS 默认 Unicode这会导致AfxMessageBox(_T())等宏报错。解决办法是把项目属性 → 常规 → 字符集改成“使用多字节字符集”或者把所有字符串都包上_T()。由于源码里多半已经用了 _T()改成多字节字符集通常最省事。如果编译时提示“无法打开 afxwin.h”说明工程没有安装 MFC 组件。打开 Visual Studio Installer找到“使用 C 的桌面开发”右侧勾选“适用于最新 v142 生成工具的 C MFC (x86 和 x64)”装完后重启 VS 再编译。4.2 运行参数与窗口标题修改编译通过后直接按 F5 运行。窗口顶部默认标题是“MazeGame”或者你在工程名里看到的资源字符串。这个标题来自对话框资源的 Caption 属性不是代码里的。如果要改标题双击 .rc 文件中的对话框资源在属性窗口修改 Caption 文本。窗口的大小同样在资源编辑器里调整。如果有自定义的启动尺寸在 OnInitDialog 里可以调用SetWindowPos。比如我要让窗口刚好显示 15×15 个格子每个格子 32 像素那么客户区宽高至少是 15×32480再加上窗口边框和标题栏最终窗口尺寸可以设为 496×535。这个数值不是固定的主要看你格子的像素数和迷宫的行列数。运行后会看到一个灰色背景的格子图玩家是一个蓝色方块终点是红色方块。此时用鼠标点击一下对话框窗口确保焦点在对话框上然后用方向键移动。如果点击后方向键没反应多半是焦点在一个看不见的按钮或编辑框上需要按 Tab 切换焦点或者点击窗口空白区域这也解释了为什么很多教程在 OnInitDialog 里强制 SetFocus。4.3 常见问题排查字符集、资源ID、焦点丢失先说字符集。如果你在 Debug 模式下遇到一堆 C2664 或 C2440 错误错误信息里提到const char *转CString基本就是 Unicode/多字节的问题。我的习惯是直接改用 Unicode然后把代码里所有字符串字面量包上_T()。老代码可能用LPSTR接收字符串在 Unicode 下会报类型不匹配需要改为LPTSTR或者TCHAR[]。资源 ID 的问题更隐蔽。MFC 对话框程序的 .rc 文件里定义了IDD_MAZEGAME_DIALOG等 ID这些数字在 Resource.h 里。如果你从别的工程拷贝了 Dlg 文件但没同步拷贝 Resource.h编译时会出现IDD_XXXX 未定义。解决办法是打开 Resource.h检查所有#define IDD_... 数字是否与源文件引用一致直接照抄#define IDD_MAZEGAME_DIALOG 102这类定义即可。不要小看这个问题我见过有人把整个 .rc 文件删了然后自己手工写对话框的纯属自找麻烦。焦点丢失是运行期最难受的问题。现象是对话框显示正常但按方向键毫无反应窗口也不重绘。原因通常是 OnInitDialog 里创建了一个按钮控件按钮默认抢走了焦点而按钮不会把方向键传给父窗口。解决方式是在 OnInitDialog 的末尾加上一句SetFocus(); // 把焦点还给对话框本身但 SetFocus 之后按钮仍然存在按 Tab 可能又切走。更稳的办法是把按钮设为不可见或者干脆用CWnd::OnKeyDown的默认实现。还有一个土办法在按钮的 WM_KEYDOWN 处理里手动发消息给父窗口但那样代码会很啰嗦。我建议这个项目里不要用 Button 控件直接用对话框空白区域响应键盘最省心。5. 避坑记录MFC迷宫游戏开发中的五个典型翻车现场5.1 方向键按下后窗口闪成白板现象玩家移动一次后整个窗口先变白然后才重新画出来闪烁严重甚至出现残影。原因没有使用双缓冲OnPaint 直接画到屏幕 DC 上。每次 Invalidate 触发 WM_ERASEBKGND 和 WM_PAINT先擦背景再绘制一擦一画之间屏幕就闪了。解决在 OnPaint 里先创建内存 DC 和兼容位图所有绘制画到内存 DC最后 BitBlt 到屏幕。另外还可以在 OnEraseBkgnd 里直接返回 TRUE跳过系统擦背景减少一次无用的清除。5.2 玩家走到边界外就消失现象从左上角往左按玩家方块不见了再按几下程序崩溃或画面乱掉。原因生成迷宫时没有把外圈墙全部初始化为 true只初始化了内部墙导致 m_map[0][0][3] 是 false左墙判定失效玩家坐标变成 -1越界访问数组。解决初始化时把所有墙设为 true再用生成算法打通的墙设为 false。这里有一个顺序要求先全量置 true然后递归生成。如果你先打墙再置 true会把打通的墙重新堵上。所以代码顺序不能乱。5.3 AfxMessageBox 弹窗卡死整个游戏现象玩家到达终点后弹出“恭喜通关”对话框关掉后游戏画面恢复正常但再按方向键没反应。原因弹出消息框后焦点被消息框抢走关掉后焦点没有回到对话框导致键盘消息不再发送到主窗口。解决每次重置游戏或弹窗后需要主动调用SetFocus()或SetForegroundWindow()。更稳妥的方式是弹窗前先把焦点保存在对话框弹窗结束后恢复焦点。或者用MessageBox的替代方案比如状态栏文字提示而不是模态弹窗。5.4 每次运行生成的迷宫一模一样现象第一次运行迷宫是某条路径关掉再开还是同一条路径。原因没有调用srand((unsigned)time(NULL))初始化随机数种子rand() 每次从头开始产生同一组伪随机数打乱方向的顺序自然不变。解决在 OnInitDialog 里加srand(static_castunsigned(time(nullptr)))。注意不要放在 GenerateMaze 里那样每次递归都会重置种子反而更容易出现重复序列。放在初始化函数开头只调用一次即可。5.5 MFC 程序编译通过但双击 exe 报缺少 DLL现象在 Release 模式下生成 exe拿到别的电脑运行提示“找不到 mfc140u.dll”或“vcruntime140.dll”。原因默认是动态链接 MFC 和 CRT系统没有安装对应运行库。解决在项目属性 → 常规 → MFC 的使用 改成“在静态库中使用 MFC”同时“C 语言标准”保持默认生成 exe 就不会带外部依赖。如果还需要 CRT 静态链接在“C/C → 代码生成 → 运行库”选“多线程(/MT)”。6. 二次开发技巧把静态迷宫升级为可配置关卡与自动寻路6.1 从硬编码数组改为文件配置现在的迷宫行列数和墙数据全部写死在代码里想换地图就得改数组再编译。更实际的改法是支持从文本文件读迷宫。比如文件里用字符表示地图# 代表墙 . 代表空地 P 代表玩家起点 E 代表终点void CMazeGameDlg::LoadMazeFromFile(const CString path) { CFile file; if (!file.Open(path, CFile::modeRead)) { AfxMessageBox(_T(打开地图失败)); return; } CString lineBuf; int row 0; while (file.ReadString(lineBuf)) { for (int col 0; col lineBuf.GetLength(); col) { TCHAR ch lineBuf.GetAt(col); if (ch _T(#)) { SetAllWalls(row, col, true); } else if (ch _T(P)) { playerX row; playerY col; } else if (ch _T(E)) { endX row; endY col; } } row; } file.Close(); }这样每个关卡就是一个 .txt 文件做关卡切换只需要换文件路径。我建议把地图渲染逻辑独立成一个DrawMaze()函数OnPaint 里只负责调用它这样不管地图来自数组还是文件绘制部分都不用改。6.2 自动求解BFS寻路的高亮显示想要一键演示通关路径可以用广度优先搜索BFS算出最短路径然后把路径格子高亮出来。这一步几乎是最能体现“MFC 不只是画格子”的改法// 返回路径坐标的 vector std::vectorCPoint CMazeGameDlg::FindPathBFS() { int dist[ROWS][COLS]; int prev[ROWS][COLS]; std::queueCPoint q; std::vectorCPoint path; memset(dist, -1, sizeof(dist)); dist[playerX][playerY] 0; q.push(CPoint(playerX, playerY)); while (!q.empty()) { CPoint cur q.front(); q.pop(); if (cur.x endX cur.y endY) { // 回溯 prev 数组构造路径 int tx endX, ty endY; while (tx ! playerX || ty ! playerY) { path.push_back(CPoint(tx, ty)); int idx prev[tx][ty]; tx tx - dx[idx]; ty ty - dy[idx]; } std::reverse(path.begin(), path.end()); return path; } for (int d 0; d 4; d) { int nx cur.x dx[d]; int ny cur.y dy[d]; if (nx 0 nx ROWS ny 0 ny COLS dist[nx][ny] -1 !maze[cur.x][cur.y][d]) { dist[nx][ny] dist[cur.x][cur.y] 1; prev[nx][ny] d; // 记录从哪个方向来的 q.push(CPoint(nx, ny)); } } } return path; // 空路径代表无解 }拿到路径后在 OnPaint 里遍历路径用黄色小圆点或半透明方块标记每个格子。注意 BFS 里prev记录的是方向编号回溯时需要反向操作坐标这块逻辑容易写错最好用CPoint对象保存“从哪个格子来”而不是只保存方向。6.3 改造验证与收尾习惯改完以后验证工作不能只看能不能编译。我一般会准备三套地图一套 5×5 小地图测试边界一套 15×15 正常地图测试游戏体验再故意放一个没有出口的地图测试 BFS 空路径处理。如果 BFS 无解界面要给出提示而不是空白这点最容易疏忽。那次我拆完这个 MFC 迷宫项目后养成了一个习惯每次拿到别人的 MFC 源码第一件事是打开 Dlg 文件搜索OnKeyDown和OnPaint先看清楚输入和输出再碰生成算法。因为 MFC 程序的“入口”其实分散在消息处理函数里不从消息链入手很难理清逻辑。从那以后我每次动手改老项目都强制自己先跑通原版、再做最小修改、最后才重构这套流程救了我很多次。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站