MFC迷宫游戏开发实战:双缓冲绘制与DFS生成算法解析 简介这是一套使用MFC编写的简单迷宫游戏完整工程面向初学Windows程序设计或C图形界面的开发者演示如何基于MFC框架搭建可交互的迷宫小游戏。资源共45个文件压缩包约2.13MB其中包含7个头文件与6个C源文件实现文档/视图架构、迷宫逻辑与玩家控制、资源脚本与位图/图标文件以及可直接运行的exe程序和步骤PNG截图便于对照源码理解界面构建、消息映射与绘图过程。游戏涉及二维数组存储迷宫、深度优先或广度优先路径搜索等经典算法通过键盘或鼠标操作完成寻路适合用来学习面向对象封装和Windows事件驱动机制。已有378人浏览学习。借助这份资料可快速上手MFC工程结构并根据提示修改迷宫地图、自定义关卡是一个能反复研读的练手范例。1. MFC迷宫游戏从拖控件到自绘的第一道坎很多人学 MFC 是从对话框拖按钮开始的拖一个 Button、拖一个 Edit 控件双击生成消息处理函数以为这就是 Windows 桌面开发的全部。但用 MFC 做一个简单的迷宫游戏会把你的认知拉回正确的轨道上迷宫游戏没有现成控件可用你得自己定义数据结构存迷宫、自己写绘制代码把格子画出来、自己处理键盘消息让玩家动起来。这一趟走完你对 CWnd、CDC、消息映射这三个 MFC 核心机制的理解会扎实很多。这个项目非常适合作为学完对话框之后的第二个练手程序也是面试时能拿得出手的“真写过代码”的证明。本文按我实际做这个迷宫的思路来拆解从数据结构选型讲到绘制和避坑全程可复现。2. 迷宫怎么生成才像迷宫数据结构与 DFS 递归回溯选型2.1 迷宫格子怎么存位运算而不是二维 bool迷宫游戏的第一件事不是画图是决定迷宫在内存里长什么样。最常见的做法是给每个格子存“四面墙是否存在”。初学者容易写成两个二维数组bool wallUp[ROWS][COLS]、bool wallRight[ROWS][COLS]四个方向四个数组访问起来啰嗦扩展一个斜向墙就改结构。我一般用一个结构体加上位标记// 每个格子的墙用 BYTE 的四个位表示1 表示该方向有墙 struct Cell { BYTE walls; // bit0上墙, bit1右墙, bit2下墙, bit3左墙 }; #define W_UP 0x01 #define W_RIGHT 0x02 #define W_DOWN 0x04 #define W_LEFT 0x08 Cell maze[ROWS][COLS];这里ROWS和COLS我用宏或者const int定死比如 16 行 12 列。不用vectorvectorCell是因为固定尺寸迷宫用 C 数组更直观访问连续内存也更快等你后面加缩放、加存档再换容器不迟。初始化时把每个Cell.walls置为W_UP | W_RIGHT | W_DOWN | W_LEFT表示所有墙都在后面生成过程就是不断拆墙。用位运算而不是四个 bool 的好处在移动检测时看得最清楚判断“玩家想往上走是否被挡住”只需要if (maze[r][c].walls W_UP)一行。四个方向的判断都是同一套逻辑不会出现wallUp[r][c] wallRight[r][c]这种越写越乱的局面。位运算在 MFC 里也常见读惯了GetStyle() WS_CHILD这种写法的人对这种风格不会陌生。2.2 DFS 递归回溯 vs 随机 Prim生成选型和对比迷宫生成算法有两大类一类是深度优先递归回溯一类是随机 Prim。两种都能生成“完美迷宫”也就是任意两点之间有且仅有一条路径的迷宫。我在做这个简单迷宫时选了 DFS 递归回溯因为它代码量最小、逻辑最好讲生成出来的迷宫主路长、岔路深玩起来更有“闯关感”。两种算法对比在实际效果上的差异很明显对比项DFS 递归回溯随机 Prim生成思路沿一条路走到黑撞墙后回退每次从墙集合里随机挑一面墙拆分支形态分支少、主路长、胡同长而深分支均匀、迷宫区域更“碎”实现代码量约 30 行无额外容器需要维护墙候选集合约 50 行生成速度O(ROWS*COLS)很快同样 O(ROWS*COLS)但常数更大玩家体验一条主路走到底方向感强道路交织更容易迷路如果你只想让迷宫生成得“看起来均匀”Prim 更好但游戏性上DFS 生成的大迷宫更有挑战性因为它的路径转折多、死胡同深玩家经常走进一个长胡同发现到头了转身出来时已经分不清方向。这正是迷宫游戏需要的体验。而且 DFS 递归回溯是理解图遍历的经典入门题写完这个算法你去看其他递归回溯的代码会轻松很多。另一个选 DFS 的原因是 MFC 程序里递归调用栈本身就够用。16x12 的迷宫递归深度最多两百多层完全不用担心栈溢出。如果你把迷宫扩到 100x100我建议改用显式栈的迭代版 DFS或者在项目属性里加大栈空间但那个量级已经不是“简单迷宫”的范畴了。2.3 用 DFS 生成一张 16x12 迷宫代码与参数说明生成函数的核心逻辑是从起点格开始随机挑选一个未访问过的相邻格拆掉两格之间的墙递归进入该格。四个方向的处理我用一个dirs数组做随机洗牌而不是写四段 if这样代码能复用同一套拆墙逻辑。void CMazeGameDlg::GenerateMaze(int r, int c) { visited[r][c] true; // visited 是 BOOL 二维数组全局或成员变量 // 四个方向用数组表示{上, 右, 下, 左} 对应的行列变化 int dr[4] { -1, 0, 1, 0 }; int dc[4] { 0, 1, 0, -1 }; // 随机打乱方向顺序保证每次生成的迷宫不一样 int dirs[4] { 0, 1, 2, 3 }; for (int i 3; i 0; i--) { int j rand() % (i 1); std::swap(dirs[i], dirs[j]); } for (int k 0; k 4; k) { int nr r dr[dirs[k]]; int nc c dc[dirs[k]]; if (nr 0 || nr ROWS || nc 0 || nc COLS) continue; // 越界 if (visited[nr][nc]) continue; // 已经访问过跳过 // 拆掉当前格与目标格之间的墙 if (dirs[k] 0) { // 目标在上方 maze[r][c].walls ~W_UP; maze[nr][nc].walls ~W_DOWN; } else if (dirs[k] 1) { // 目标在右方 maze[r][c].walls ~W_RIGHT; maze[nr][nc].walls ~W_LEFT; } else if (dirs[k] 2) { // 目标在下方 maze[r][c].walls ~W_DOWN; maze[nr][nc].walls ~W_UP; } else { // 目标在左方 maze[r][c].walls ~W_LEFT; maze[nr][nc].walls ~W_RIGHT; } GenerateMaze(nr, nc); // 递归进入下一个格子 } }调用时从(0, 0)开始GenerateMaze(0, 0)算法跑完整张迷宫里所有格子都连成了一条通路而且保证没有环路。关键参数就两个一个是迷宫尺寸ROWS/COLS我取 16x12 是为了配合每个格子 20 像素的绘制尺寸最终窗口客户区大约 320x240不需要滚动条另一个是随机数种子建议在OnInitDialog里调用srand((unsigned)time(NULL))否则每次运行生成的是同一张迷宫玩一遍就腻了。这里的“先拆墙、再递归”和“先标记 visited、再进入”有一个顺序上的讲究必须在递归调用之前就把两格之间的墙拆掉。原因很简单递归进入邻格之后可能马上又从邻格往回走到当前格如果墙没拆就会形成一堵格格不入的断墙迷宫看起来有一条路、实际走不通。3. 画格子与走格子双缓冲绘制和键盘移动的完整实现3.1 为什么要双缓冲闪烁的根源和 OnEraseBkgnd迷宫生成完接下来的问题是把它画到窗口上。直接在OnPaint里拿CPaintDC画墙、画地板窗口一刷新就会狂闪。原因每个 MFC 初学者都遇过窗口在重绘时先擦背景再画前景擦是白屏画是迷宫两个动作交替人眼看到的是一亮一暗的“频闪”。窗口越大、绘制逻辑越重闪烁越明显。解决思路是双缓冲先在一张内存位图上把迷宫完整画好再一次BitBlt把整张图拷贝到窗口上。这样用户看不到“画了一半”的中间状态只看到最终结果。双缓冲是 Windows 绘图里最基础也最有效的性能手段在迷宫、游戏、自定义控件里都是必用方案。配合双缓冲还要做一件事把背景擦除干掉。MFC 的默认窗口类会在WM_ERASEBKGND消息里用背景色刷一遍窗口这就是闪烁的另一个源头。标准做法是重写OnEraseBkgnd直接返回 TRUE 告诉系统“背景你已经处理完了别擦”。BOOL CMazeGameDlg::OnEraseBkgnd(CDC* pDC) { // 返回 TRUE 阻止系统默认擦背景避免闪屏 return TRUE; }注意这个函数在类向导里看不到需要手动在消息映射里加ON_WM_ERASEBKGND()。加上之后背景擦除被禁用所有刷新都在内存 DC 中完成再BitBlt上屏。3.2 OnPaint 里画墙、玩家与终点核心绘制代码绘制代码放在OnPaint里用CPaintDC获取窗口 DC再创建兼容 DC 和位图作为缓冲。这里有一个细节CreateCompatibleBitmap的尺寸要和客户区一致如果你让窗口可以缩放则每次重绘要重新创建位图比较麻烦。做简单迷宫我建议禁掉窗口缩放固定尺寸代码干净很多。void CMazeGameDlg::OnPaint() { CPaintDC dc(this); // 窗口 DC CRect rc; GetClientRect(rc); // 创建内存 DC 和位图做双缓冲 CDC memDC; memDC.CreateCompatibleDC(dc); CBitmap bmp; bmp.CreateCompatibleBitmap(dc, rc.Width(), rc.Height()); CBitmap* pOldBmp memDC.SelectObject(bmp); // 背景色 memDC.FillSolidRect(rc, RGB(240, 240, 240)); // 画迷宫先地板后墙保证边界线干净 for (int r 0; r ROWS; r) { for (int c 0; c COLS; c) { int x c * CELL_SIZE; int y r * CELL_SIZE; // 地板 memDC.FillSolidRect(x, y, CELL_SIZE, CELL_SIZE, RGB(255, 255, 255)); // 四个方向的墙画 2 像素宽右/下方向多画 1 像素防止漏缝 if (maze[r][c].walls W_UP) memDC.FillSolidRect(x, y, CELL_SIZE 1, 2, RGB(60, 60, 60)); if (maze[r][c].walls W_RIGHT) memDC.FillSolidRect(x CELL_SIZE - 1, y, 2, CELL_SIZE 1, RGB(60, 60, 60)); if (maze[r][c].walls W_DOWN) memDC.FillSolidRect(x, y CELL_SIZE - 1, CELL_SIZE 1, 2, RGB(60, 60, 60)); if (maze[r][c].walls W_LEFT) memDC.FillSolidRect(x, y, 2, CELL_SIZE 1, RGB(60, 60, 60)); } } // 画终点右下角格子里画一个绿色方块 CBrush greenBrush(RGB(0, 180, 0)); CBrush* pOldBrush memDC.SelectObject(greenBrush); int ex (COLS - 1) * CELL_SIZE CELL_SIZE / 4; int ey (ROWS - 1) * CELL_SIZE CELL_SIZE / 4; memDC.Rectangle(ex, ey, ex CELL_SIZE / 2, ey CELL_SIZE / 2); memDC.SelectObject(pOldBrush); // 还原画刷 // 画玩家当前格子中心的蓝色圆 CBrush blueBrush(RGB(0, 120, 255)); pOldBrush memDC.SelectObject(blueBrush); int px playerCol * CELL_SIZE CELL_SIZE / 4; int py playerRow * CELL_SIZE CELL_SIZE / 4; memDC.Ellipse(px, py, px CELL_SIZE / 2, py CELL_SIZE / 2); memDC.SelectObject(pOldBrush); // 一次拷贝到窗口 dc.BitBlt(0, 0, rc.Width(), rc.Height(), memDC, 0, 0, SRCCOPY); // 清理先把旧位图选回来再删对象 memDC.SelectObject(pOldBmp); bmp.DeleteObject(); }参数说明CELL_SIZE设 20迷宫格子 20x20 像素墙厚 2 像素。两点值得注意。第一右边和下边的墙画得比格子边界多 1 像素是因为相邻两个格子各自画墙时如果不做重叠处理格子之间会出现一条白色细缝视觉上就是墙“断”了。这是血泪经验我最早画出来满屏白线排查半天发现是边界差 1 像素。第二先画地板再画墙顺序不能反。如果先画墙地板会把墙盖掉一半出现锯齿边。绘制顺序是窗口自绘的通用纪律尤其在做地图、棋盘这类网格图形时。另外画完玩家后有一行SelectObject还原旧画刷这步很多人忽略。在内存 DC 里不还原问题不大但如果连续绘制多次或者后续使用同一个 DC旧对象不还原会导致 GDI 对象泄漏。MFC 的CDC析构时虽然会清理但养成“Select 了就要 Select 回来”的习惯能少踩很多坑。3.3 PreTranslateMessage 里的键盘移动与碰撞检测玩家移动需要响应键盘方向键。MFC 对话框程序有几种做法重写OnKeyDown、在PreTranslateMessage里拦截虚键消息。我推荐用PreTranslateMessage一个核心原因是对话框里如果有按钮或其他控件焦点不在对话框本身时WM_KEYDOWN不一定会走到对话框的OnKeyDown表现为“点了一下按钮之后方向键失灵了”。而PreTranslateMessage在消息被分发到任何窗口之前先过一道手方向键在这里拦截最稳。BOOL CMazeGameDlg::PreTranslateMessage(MSG* pMsg) { if (pMsg-message WM_KEYDOWN) { UINT key (UINT)pMsg-wParam; int nr playerRow; int nc playerCol; if (key VK_UP) nr--; else if (key VK_DOWN) nr; else if (key VK_LEFT) nc--; else if (key VK_RIGHT) nc; else return CDialogEx::PreTranslateMessage(pMsg); // 其他键走默认 // 先判断目标位置是否在迷宫范围内 if (nr 0 nr ROWS nc 0 nc COLS) { // 判断当前格与目标格之间的墙是否挡住移动 bool blocked false; if (key VK_UP (maze[playerRow][playerCol].walls W_UP)) blocked true; if (key VK_DOWN (maze[playerRow][playerCol].walls W_DOWN)) blocked true; if (key VK_LEFT (maze[playerRow][playerCol].walls W_LEFT)) blocked true; if (key VK_RIGHT (maze[playerRow][playerCol].walls W_RIGHT)) blocked true; if (!blocked) { playerRow nr; playerCol nc; stepCount; // 步数统计后面可用 Invalidate(FALSE); // 仅重绘客户区不擦背景 } } return TRUE; // 方向键已处理不再分发 } return CDialogEx::PreTranslateMessage(pMsg); }这段的碰撞检测只检查“当前格子在自己移动方向上的墙”不需要查目标格子的反向墙因为两格之间的墙在生成时是同步拆掉的墙的状态永远成对存在。这是一个依赖算法保证的简化DFS 生成时拆了当前格的上墙必然拆了目标格的下墙所以单向判定足够。Invalidate(FALSE)的参数FALSE表示重绘时不擦背景。配合前面OnEraseBkgnd返回 TRUE整个刷新路径里没有任何擦背景的步骤玩家移动时画面干净利落。如果你不传 FALSE默认是擦背景移动时会有白闪但这在双缓冲之下影响不大只是养成写 FALSE 的习惯总没错。玩家的初始位置是(0, 0)终点是(ROWS-1, COLS-1)。如果你玩着玩着发现“起点和终点居然通不了”那不是移动代码的问题是生成算法里墙拆错了回到 2.3 检查拆墙逻辑。4. MFC 迷宫开发的 5 个常见坑现象、原因、解决4.1 内存泄漏CString 的 GetBuffer 和 new 出来的迷宫数组现象程序运行完Visual Studio 输出窗口显示Detected memory leaks!一堆带{1234}块号的分配记录定位半天也不知道是哪里的。原因两个高频来源。第一有人用new动态创建二维数组int** maze new int*[ROWS]释放时只写了delete[] maze没有逐行delete[] maze[i]导致每一行的数组都泄漏。第二MFC 里CString::GetBuffer(len)之后忘记调用ReleaseBuffer()CString 内部缓冲区不完整析构时可能触发断言或泄漏。迷宫项目里用CString做“步数提示”或“胜利提示”时最容易踩这个。解决迷宫数组直接改成std::vectorstd::vectorCell在OnInitDialog里resize好析构自动释放一行维护代码都不用写。CString必须用GetBuffer的场合一定配对ReleaseBuffer。写完后开着内存泄漏检测跑三局游戏输出窗口还能看到泄漏就继续查查干净再提交。这条简直是血泪经验我早期写的 MFC 程序十个有八个挂着泄漏运行一晚上内存涨几百兆。4.2 窗口闪烁缺了 WM_ERASEBKGND 开关现象迷宫画出来了但每次Invalidate触发重绘窗口先白屏闪一下然后才显示新画面。快速按方向键移动玩家时画面闪得眼睛疼。原因OnPaint里虽然有双缓冲但系统默认仍然在绘图前擦除背景WM_ERASEBKGND用背景色把客户区刷成白色然后再走OnPaint画迷宫。双缓冲解决的是“绘制过程可见”背景擦除解决的是“旧画面残留”两个都要处理只做双缓冲就漏了一半。解决在消息映射里加ON_WM_ERASEBKGND()然后重写BOOL CMazeGameDlg::OnEraseBkgnd(CDC* pDC) { return TRUE; // 告诉系统背景已经处理好跳过擦除 }改完之后再做快速键盘移动测试画面应该没有白闪。如果仍有轻微闪烁检查Invalidate调用是否传了FALSE传TRUE会强制擦除背景和上面的设置互相抵消。4.3 坐标映射错位白线、缝隙与 CELL_SIZE 不一致现象迷宫画出来后墙壁之间散布着很多细白线或者玩家明明走到了某个位置却被判定撞墙感觉格子边界和视觉边界对不上。原因两个层面的问题。白线是画墙时右、下两个方向没有做 1 像素重叠相邻格子各画各的墙中间留了一条约 1 像素的空白。判定错位则是因为绘制用了CELL_SIZE做坐标换算但实际画图时某个地方用了别的尺寸比如把窗口客户区缩放后按比例计算格子大小移动逻辑却还在用常量CELL_SIZE两边数值不一致。解决画墙时按 3.2 的做法右边墙 X 坐标用x CELL_SIZE - 1、宽度2下边墙同理多画 1 像素。更省事的方法是画整条线段而不是画矩形直接用MoveTo/LineTo把墙当作线绘制但线的宽度控制没有矩形直观。边界判定问题最简单的方案是固定窗口大小不要允许缩放OnInitDialog里调用SetWindowPos把窗口设成刚好放下迷宫再加一圈边距的大小让CELL_SIZE成为唯一尺寸来源。没有缩放就没有换算没有换算就没有错位。4.4 Unicode 字符集CString 和 char* 混用带来的报错现象代码里写CString str 移动步数: stepCount;编译报错或者把CString传给某个char*参数的函数时输出乱码更夸张的情况是安安静静地编译通过、运行结果全是乱码。原因Visual Studio 的 MFC 项目默认字符集是 UnicodeCString在 Unicode 下内部存的是宽字符和char*不是一回事。字符串字面量移动步数在 Unicode 项目里是const char[]赋值给CString时发生隐式转换但如果你反过来把CString强转给char*那只是把指针硬掰过去了数据完全不对。解决所有字符串字面量统一写成_T(移动步数)或L移动步数格式化用CString::Format而不是字符串拼接CString strMsg; strMsg.Format(_T(移动步数: %d), stepCount); SetWindowText(strMsg);这个坑的隐蔽之处在于小规模测试时混用char*和CString往往不报错因为有些重载函数同时接受两种参数编译过了就以为没事。等到中文路径、中文提示全部乱码的时候才反应过来是字符集问题。建议整个项目统一使用 CString 和_T()宏一句char*都不要出现。4.5 方向键失灵焦点被控件吃掉现象程序刚启动时方向键能控制玩家移动但一旦用鼠标点过别的按钮或者编辑框方向键就完全没有反应了点回窗口空白处又恢复。原因对话框里所有控件都是子窗口每个子窗口都能获得焦点。方向键的WM_KEYDOWN消息会发给当前拥有焦点的窗口如果焦点在一个按钮上按钮把方向键消息自己处理掉了对话框的OnKeyDown根本收不到。这就是为什么我把移动逻辑放在PreTranslateMessage里它在消息分发的最前端拦截跟焦点无关。解决如 3.3 所示重写PreTranslateMessage在方向键消息到达任何控件之前先处理处理完返回TRUE阻止继续分发。同时把对话框本身设为默认焦点OnInitDialog里调用SetFocus到迷宫绘图区域。如果你用视图类而不是对话框类则要用OnKeyDown配合SetFocus处理但原理一样焦点是键盘消息的分发依据截获位置要选在焦点机制的上游。5. 进阶技巧连通性验证、难度梯度与撤销一步迷宫写完之后怎么证明它真的是一张可解迷宫拿人肉跑一遍太慢我在调试阶段会写一个验证函数从起点用 BFS 搜索到终点搜到了说明路是通的同时把最短路径长度也算出来和玩家实际步数对比就能判断玩家绕了多远。void CMazeGameDlg::ValidateMaze() { // 用 BFS 从起点找终点同时记录步数 int dist[ROWS][COLS]; memset(dist, -1, sizeof(dist)); std::queuestd::pairint, int q; q.push({0, 0}); dist[0][0] 0; int dr[4] { -1, 0, 1, 0 }; int dc[4] { 0, 1, 0, -1 }; while (!q.empty()) { auto [r, c] q.front(); q.pop(); for (int d 0; d 4; d) { // 当前位置的墙决定了能否向 d 方向走 if ((maze[r][c].walls (1 d)) ! 0) continue; int nr r dr[d]; int nc c dc[d]; if (nr 0 || nr ROWS || nc 0 || nc COLS) continue; if (dist[nr][nc] ! -1) continue; dist[nr][nc] dist[r][c] 1; q.push({nr, nc}); } } TRACE(_T(终点最短步数: %d\n), dist[ROWS - 1][COLS - 1]); }这个验证函数建议每次修改迷宫生成算法后都跑一遍只花几毫秒能拦住大部分“看着连通实际走不通”的情况。生成质量还可以用死路比例衡量——统计四面都有墙、只有三面墙入口除外的格子数量死路太多说明算法参数需要调比如把 DFS 的随机方向洗牌改得更激进。迷宫本体做完了我往这个小项目里加过两个小功能成本很低但很提升完成度。一个是计时器SetTimer每 1 秒触发一次WM_TIMER在窗口标题栏显示已用时间玩家到达终点后KillTimer停表配合步数统计做一个简单的“步数时间”双指标。另一个是后悔药维护一个std::stackstd::pairint, int保存历史位置按 Backspace 键时把playerRow/playerCol恢复为栈顶值并弹栈实现撤销一步。这个功能对测试特别有用走到绝路想回头看看不用自己慢慢原路摸回去。做这个小迷宫的过程里我最大的一个习惯收获是每次改完代码先跑验证函数看最短路径步数再手动玩一局最后看输出窗口有没有泄漏三步全过才算完。不要急着加新功能基础的东西宁可慢一点也要稳。希望帮到你。本文还有配套的精品资源点击获取