简介:一个基于MFC类库的简单迷宫游戏完整源码包,面向C++初学者和需要课程设计参考的开发者,展示了MFC对Windows API的封装在窗口、控件、绘图与消息处理中的实际应用。项目使用二维网格表示迷宫地图,利用二维数组存储墙与通道状态,并借助深度优先或广度优先搜索寻找起点到终点的路径;玩家可通过自定义交互逻辑移动、重置游戏,代码中还包括位置状态记录和边界判断等细节。压缩包共45个文件,约2.13MB,其中7个C++头文件与6个源文件构成工程主体,配以Visual C++工程配置、已编译exe、调试文件和png/bmp/ico等素材,便于直接运行和对照阅读。目前已有378人学习下载。资源内附游戏思路说明和步骤截图,可参考如何修改或创建自己的迷宫地图;整体适合MFC入门练手,也能为Windows程序设计或游戏开发课设提供直观范例。
1. 用MFC做迷宫游戏:从“能画出来”到“玩得起来”的完整闭环
如果你已经啃过一阵子MFC,却总觉得自己写的界面程序只是摆了几个按钮,那迷宫游戏是你绕不开的练手题。一个简单的MFC迷宫游戏,不光是画一张格子图,它把图论里的生成算法、Windows消息机制、GDI绘图、还有最基础的碰撞检测都揉在了一起。做完这个,你对MFC的视图刷新、消息路由和内存DC的理解会上一整个台阶。哪怕现在主流框架早不是它,MFC的这套消息驱动和自绘思路依然是理解桌面应用的好底子。这篇笔记不讲大道理,直接带你从数据设计写到自动求解,顺便把我在这个过程中踩过的坑一个一个挑出来。适合那些想用MFC做点“完整小项目”、又怕只停留在控件的开发者和学生。
2. 迷宫游戏的技术拆解:选对算法和框架,事情成一半
动手写代码之前,先把方案定死。迷宫游戏看起来小,但技术选型不对,后面全是补丁。
2.1 迷宫生成的递归回溯算法:简单、可靠、可解释
迷宫生成算法很多,常见的有递归回溯、Prim、Kruskal。我最终选了递归回溯,也就是深度优先搜索生成迷宫。原因很简单:它生成的迷宫带有明显的一条长路径,视觉上更有“迷宫感”,而且代码量少,核心逻辑半小时讲得清。它还有个特点叫“完美迷宫”——没有环,任意两点之间有且只有一条路径,这对后面做自动寻路和判断胜利都非常友好。
递归回溯的思路是这样的:从起点格子开始,标记为已访问。检查当前格子四周有没有未访问的邻居,如果有,随机选一个,打通两者之间的墙,然后递归走向邻居。如果周围都访问过了,就回退到上一个格子,继续找路。反复执行,直到所有格子都访问过,迷宫就生成完了。递归在这儿的深度最多是格子数,100×100 的迷宫用默认调用栈也没问题。
这里有一个参数直接决定了迷宫观感:格子大小。我一般用 21×21 或 31×31 的奇数尺寸。为什么非得奇数?因为走迷宫的角色占一个格子,起点和终点通常放在顶角和底角,奇数尺寸保证中心有格子可用,而且奇数配合边界判断更直观。如果你用偶数,墙壁和通道的对称性会被破坏,看起来总有那么一排不对劲。
2.2 为什么MFC单文档视图比对话框更适合做游戏循环
先聊聊框架选型。MFC里做这种实时交互的东西,有两种常见选择:一是对话框程序,放一个自绘控件;二是单文档视图(SDI)架构。我一般直接选后者。原因是迷宫游戏的核心是不断重绘格子,单文档的View视图天然就是一块画布,OnPaint里画什么、鼠标键盘消息往哪儿发,生命周期都是框架管好的,你不用自己造轮子。对话框也不是不行,但你要额外处理子控件的消息路由,还经常碰焦点问题——按钮一按,键盘消息就被按钮吃了,游戏角色不动了。这个问题在单文档视图里很少出现。
另一个关键点是视类(CView)的消息映射。MFC之所以让人又爱又恨,就是它把C++的虚函数和窗口消息封装成了消息映射宏。你要响应键盘,就重写PreTranslateMessage或者OnKeyDown。我推荐OnKeyDown,它只管按键动作,不会和系统命令冲突。PreTranslateMessage是最后一道关口,搞不好会拦截掉 Alt 键组合,调试起来很玄学。迷宫游戏只需要四向方向键,OnKeyDown足够。
2.3 数据结构和坐标换算:格子、墙壁和像素
数据结构设计得好不好,直接决定后面画图和移动判断的代码能不能复用。我常用的结构如下:迷宫是二维数组,每个格子是一个 unsigned char,用低四位表示四面墙是否存在。定义是:第0位表示上墙,第1位表示右墙,第2位表示下墙,第3位表示左墙。初始值每个格子都是 0x0F,也就是四面墙都在。生成迷宫时,打通一面墙就把对应格子的那一位置 0,同时把相邻格子相对方向的墙也置 0。这样迷宫数据本身是自洽的,后面移动判断只要按位与检查墙位就行。
坐标换算要提前定好规则。逻辑坐标使用格子坐标(row, col),绘制坐标使用窗口客户区的像素坐标。像素坐标等于格子坐标乘以格子边长,再加上偏移。不要把像素和格子混在一个变量里,否则到后面写自动寻路的时候你会发现,明明路径是对的,画出来却穿墙而过。这里我踩过一次,后文避坑部分细说。
看一段数据结构定义:
// maze.h #pragma once #include <vector> #include <random> class CMaze { public: CMaze(int rows, int cols); void generate(int startRow, int startCol); // 递归回溯生成 bool isWall(int row, int col, int dir) const; // 判断某面墙是否存在 void carveWall(int row, int col, int dir); // 打通一面墙 int getRows() const { return m_rows; } int getCols() const { return m_cols; } private: int m_rows, m_cols; std::vector<unsigned char> m_cells; // 每个格子墙状态 };逻辑说明:构造函数根据行数和列数创建一维vector,但逻辑上用索引 row * cols + col 访问。这样比vector 快,而且连续内存对MFC的绘制批量操作也友好。generate方法从入口开始递归,isWall和carveWall是给外部统一接口。方向常量建议单独定义:上=0,右=1,下=2,左=3。这样数组里存方向差量时也好算。
参数说明:rows和cols必须大于1。如果你让它变成0或者负数,vector会直接抛异常,MFC程序弹出一堆英文错误框,让人摸不着头脑。我在工程入口处做了保护,小于3的尺寸直接重置为11。这不是什么高深技巧,但能避免很多低级闪退。
3. 用MFC把迷宫画出来:初始化、OnPaint和双缓冲
这一步的目标是在窗口上看到一个完整的迷宫。很多新手卡在这里,是因为搞不清MFC的“重绘”机制:你画了迷宫,只要窗口被遮挡再恢复,内容就没了。所以必须把绘制代码放在OnPaint里,让系统每次需要重绘时自动调用。这也是MFC做游戏和做普通对话框最大的区别。
3.1 工程初始化与迷宫数据成员
创建工程的时候,用MFC应用向导,选“单文档”类型。视图基类选CView,不要选CScrollView,迷宫尺寸固定,不需要滚动条。在CMainFrame和CView之间,迷宫对象放在视类里作为成员。为什么放在视类?因为迷宫需要响应绘制和键盘操作,放文档类里还要绕一次指针传递,没必要。做小项目,一切从简。
写一个迷宫对象成员:
// MazeView.h class CMazeView : public CView { public: CMazeView(); virtual ~CMazeView(); protected: CMaze m_maze{ 21, 21 }; // 迷宫对象 int m_cellSize = 20; // 每个格子像素 int m_playerRow = 0; // 玩家所在行 int m_playerCol = 0; // 玩家所在列 bool m_gameWin = false; // 是否到达终点 };逻辑说明:m_maze在声明时直接初始化,天数不变。m_cellSize是绘制时的缩放单位,20像素一格,21×21迷宫画出来大约是420×420像素,在普通窗口里刚好。玩家初始位置在(0,0),终点放在右下角(row-1, col-1)。这个class还可以加更多状态,但初始这些已经够跑通基本流程。
参数说明:m_cellSize如果太小,比如小于10像素,画出来的墙线和格子挤成一团,眼睛受不了。如果太大,窗口显示不下,又得依赖滚动视图或缩窗。20是个均衡点。如果你用31×31迷宫,建议把格大小调到14或者15,让总尺寸保持在450左右。
3.2 OnPaint中绘制迷宫格线和墙壁
接下来是核心绘制代码。CView的OnPaint最稳的做法是:先构造CPaintDC,然后获取客户区矩形,把迷宫画在中央偏左上区域。画墙用LineTo,画通道则什么都不画,只填充底色。为了让终点和入口一眼能认出来,我还会用两种不同颜色画起点和终点填充矩形。
// MazeView.cpp void CMazeView::OnPaint() { CPaintDC dc(this); CRect rc; GetClientRect(&rc); // 使用双缓冲,避免画面闪烁 CDC memDC; CBitmap bmp; memDC.CreateCompatibleDC(&dc); bmp.CreateCompatibleBitmap(&dc, rc.Width(), rc.Height()); CBitmap* pOld = memDC.SelectObject(&bmp); // 背景填充 CBrush bgBrush(RGB(240, 240, 240)); memDC.FillRect(rc, &bgBrush); // 迷宫偏移,让迷宫在窗口居中 int offsetX = (rc.Width() - m_maze.getCols() * m_cellSize) / 2; int offsetY = (rc.Height() - m_maze.getRows() * m_cellSize) / 2; // 画墙 CPen wallPen(PS_SOLID, 2, RGB(50, 50, 50)); CPen* pOldPen = memDC.SelectObject(&wallPen); for (int row = 0; row < m_maze.getRows(); ++row) { for (int col = 0; col < m_maze.getCols(); ++col) { int x0 = offsetX + col * m_cellSize; int y0 = offsetY + row * m_cellSize; int x1 = x0 + m_cellSize; int y1 = y0 + m_cellSize; // 上墙 if (m_maze.isWall(row, col, 0)) { memDC.MoveTo(x0, y0); memDC.LineTo(x1, y0); } // 右墙 if (m_maze.isWall(row, col, 1)) { memDC.MoveTo(x1, y0); memDC.LineTo(x1, y1); } // 下墙 if (m_maze.isWall(row, col, 2)) { memDC.MoveTo(x1, y1); memDC.LineTo(x0, y1); } // 左墙 if (m_maze.isWall(row, col, 3)) { memDC.MoveTo(x0, y1); memDC.LineTo(x0, y0); } } } // 起点和终点 CBrush startBrush(RGB(0, 180, 0)); int sx = offsetX + 0 * m_cellSize + 2; int sy = offsetY + 0 * m_cellSize + 2; memDC.FillRect(CRect(sx, sy, sx + m_cellSize - 4, sy + m_cellSize - 4), &startBrush); CBrush endBrush(RGB(220, 0, 0)); int ex = offsetX + (m_maze.getCols()-1) * m_cellSize + 2; int ey = offsetY + (m_maze.getRows()-1) * m_cellSize + 2; memDC.FillRect(CRect(ex, ey, ex + m_cellSize - 4, ey + m_cellSize - 4), &endBrush); // 玩家角色 if (!m_gameWin) { CBrush playerBrush(RGB(0, 100, 255)); int px = offsetX + m_playerCol * m_cellSize + 4; int py = offsetY + m_playerRow * m_cellSize + 4; memDC.FillRect(CRect(px, py, px + m_cellSize - 8, py + m_cellSize - 8), &playerBrush); } // 最后把隐藏画布一次性贴回窗口 dc.BitBlt(0, 0, rc.Width(), rc.Height(), &memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldPen); memDC.SelectObject(pOld); wallPen.DeleteObject(); bmp.DeleteObject(); }逻辑说明:这段代码把整个迷宫画到内存DC上,最后BitBlt一次性显示。好处是不闪烁。如果你直接在CPaintDC上画,窗口每次刷新都会先擦白再画,方向键连按的时候画面会闪得像老式电视机。双缓冲是MFC绘图里最实用的套路,代码多写十来行,体验完全不同。
参数说明:墙笔的宽度设为2像素,太细则格子边界不明显,太粗会盖住通道,玩家矩形又会显得拥挤。起点和终点用矩形填充,颜色选绿和红,这是通用直觉,不需要额外图例。玩家矩形比格子略小一圈,让它看起来在通道里“走动”而不是贴满格子,视觉反馈更舒服。
3.3 让迷宫每次生成都不同:随机种子的坑
递归回溯算法里用到了随机数。MFC工程的C++标准库随机数在Visual C++里行为有些特殊,很多人直接srand((unsigned)time(NULL)),然后rand()%4来选方向,这样写在小程序里没问题。不过现代C++标准库提供了更好的std::mt19937。我建议用后者,原因是它分布均匀,避免某面墙一直不被选中导致迷宫看起来有一大片空地。
void CMaze::generate(int startRow, int startCol) { // 先用mt19937做随机源 static std::mt19937 rng(std::random_device{}()); std::uniform_int_distribution<int> dist(0, 3); // 四个方向 // 标记访问状态的向量 std::vector<bool> visited(m_rows * m_cols, false); // 用栈模拟递归,避免深递归在超大迷宫里爆栈 std::vector<int> stack; stack.push_back(startRow * m_cols + startCol); visited[startRow * m_cols + startCol] = true; while (!stack.empty()) { int current = stack.back(); int r = current / m_cols; int c = current % m_cols; // 收集当前格子所有未访问邻居 std::vector<std::pair<int, int>> neighbors; // 上:r-1, c if (r > 0 && !visited[(r-1) * m_cols + c]) neighbors.push_back({r-1, c}); // 下:r+1, c if (r < m_rows-1 && !visited[(r+1) * m_cols + c]) neighbors.push_back({r+1, c}); // 左:r, c-1 if (c > 0 && !visited[r * m_cols + (c-1)]) neighbors.push_back({r, c-1}); // 右:r, c+1 if (c < m_cols-1 && !visited[r * m_cols + (c+1)]) neighbors.push_back({r, c+1}); if (!neighbors.empty()) { // 随机选一个邻居 auto next = neighbors[dist(rng) % neighbors.size()]; int nr = next.first; int nc = next.second; // 打通当前格子到邻居的墙 if (nr == r-1) { carveWall(r, c, 0); carveWall(nr, nc, 2); } else if (nr == r+1) { carveWall(r, c, 2); carveWall(nr, nc, 0); } else if (nc == c-1) { carveWall(r, c, 3); carveWall(nr, nc, 1); } else if (nc == c+1) { carveWall(r, c, 1); carveWall(nr, nc, 3); } visited[nr * m_cols + nc] = true; stack.push_back(nr * m_cols + nc); } else { // 没有未访问邻居,回退 stack.pop_back(); } } }逻辑说明:我用了显式栈而不是递归,这是为了以后要扩展大迷宫时不心虚。递归回溯本质是深度优先,用栈模拟完全可以。生成完后,迷宫中任意两格之间只有一条路,如果我在这个基础上加环路,那就在生成之后随机再拆几面墙,但现在用不上。这个算法的时间复杂度接近O(rows*cols),三维数组规模不大时,瞬间完成。
参数说明:dist(0,3)会平等产生0、1、2、3。如果直接用rand()%4,在有些编译器实现里低位随机性不好,选方向会偏。mt19937的初始化用了random_device,每次运行种子不同,迷宫也完全不同。如果你希望某一次迷宫能复现,可以把种子打印出来,下次用固定种子初始化,这是一个调试技巧,后面再提。
4. 玩家移动和胜利判定:让迷宫变成游戏的关键一步
迷宫画出来只是静态图片,真正的游戏要能响应键盘。MFC视图类里重写OnKeyDown,在这个函数里判断方向键,修改玩家位置,再Invalidate()触发重绘。有人说这算不算游戏循环?严格意义不算,但小游戏这样写很自然,它用“事件驱动”代替了定时循环。如果要做人物自动前进或者动画,就需要用定时器了,最后章会讲。
4.1 重写OnKeyDown响应方向键
在视类头文件里声明消息处理函数,然后在cpp里加消息映射。MFC的宏看起来有点丑,但熟悉后就会觉得它比手动维护一个巨大虚函数表直观。注意:方向键是系统虚拟键,用VK_LEFT、VK_RIGHT、VK_UP、VK_DOWN判断。
// MazeView.cpp BEGIN_MESSAGE_MAP(CMazeView, CView) ON_WM_KEYDOWN() ON_WM_PAINT() END_MESSAGE_MAP() void CMazeView::OnKeyDown(UINT nChar, UINT nRepCnt, UINT nFlags) { if (m_gameWin) { CView::OnKeyDown(nChar, nRepCnt, nFlags); return; } bool moved = false; if (nChar == VK_UP) moved = tryMovePlayer(-1, 0); else if (nChar == VK_DOWN) moved = tryMovePlayer(1, 0); else if (nChar == VK_LEFT) moved = tryMovePlayer(0, -1); else if (nChar == VK_RIGHT) moved = tryMovePlayer(0, 1); if (moved) { if (m_playerRow == m_maze.getRows()-1 && m_playerCol == m_maze.getCols()-1) { m_gameWin = true; // 这里可以弹出消息,也可以只是状态变化 } Invalidate(FALSE); // 参数FALSE表示不擦除背景,配合双缓冲减少闪烁 } CView::OnKeyDown(nChar, nRepCnt, nFlags); }逻辑说明:检测胜利放在move之后。m_gameWin为true时,玩家不能再移动,OnKeyDown直接交给父类处理,避免游戏结束后还能乱跑。Invalidate(FALSE)和OnPaint里的双缓冲搭配,是画面流畅的关键。TRUE会让系统先擦背景,造成短暂闪白。
参数说明:nRepCnt表示按键重复计数,长时间按住方向键时会多次触发,这个功能帮玩家快速连续移动。如果你不想让玩家走那么快,可以在函数里加一个计时判断,比如只处理第一次按下,忽略重复触发。但迷宫游戏里快速移动是正常需求,保留默认即可。
4.2 移动时的穿墙判断:位运算是最快的检查
tryMovePlayer是核心函数,负责检查目标格子是否可进入。很多人会直接检查目标格子是否越界,再检查当前格子的墙。实际上还要检查目标格子相对方向的墙是否存在,因为如果只拆了当前格子的墙,目标格子的墙没拆,就会出现“单面墙”的bug。用递归回溯算法生成时,两个格子的墙是一起拆的,但假如以后你手动编辑迷宫数据,就可能会遇到这种不对称。
下面是tryMovePlayer实现:
bool CMazeView::tryMovePlayer(int dRow, int dCol) { int newRow = m_playerRow + dRow; int newCol = m_playerCol + dCol; // 越界检查 if (newRow < 0 || newRow >= m_maze.getRows() || newCol < 0 || newCol >= m_maze.getCols()) { return false; } // 判断方向对应的墙位 int dir; if (dRow == -1) dir = 0; // 上 else if (dRow == 1) dir = 2; // 下 else if (dCol == -1) dir = 3; // 左 else dir = 1; // 右 if (m_maze.isWall(m_playerRow, m_playerCol, dir)) { return false; // 当前格子这面墙还在,不允许穿墙 } // 再检查目标格子的反向墙 int opposite = (dir + 2) % 4; if (m_maze.isWall(newRow, newCol, opposite)) { return false; } // 可以移动 m_playerRow = newRow; m_playerCol = newCol; return true; }逻辑说明:先做越界检查,再做墙检查。这个顺序不能反,否则在边界时访问isWall会数组越界,轻则读到脏数据,重则崩溃。isWall实现里用位运算判断:return (m_cells[row*cols+col] & (1 << dir)) != 0;,四个方向刚好占用一个字节的低四位。位运算比成员变量判断更省内存,而且不容易打字打错。
参数说明:opposite的计算是(dir + 2) % 4。上(0)的对面是下(2),右(1)的对面是左(3),这个公式简洁且不容易漏。如果你用 if 分支写四行,也能跑,但代码冗余,维护时容易把方向写反,产生“能穿墙”的严重逻辑错误。
4.3 绘制角色与终点:OnPaint里的玩家矩形
玩家位置更新后,Invalidate会触发OnPaint重绘。我在前面的OnPaint里已经根据m_playerRow和m_playerCol画玩家的蓝色矩形。这里有个细节:当玩家到达终点后,玩家矩形不再绘制,因为m_gameWin为true。你可以换成弹窗提示胜利,但弹窗会打断下一次键盘输入,用户体验一般。我更喜欢在窗口标题上显示“胜利”,或者在角色位置画一个夸张的表情字符。
绘制这部分还有一个优化:不需要把整个迷宫重画,可以只重绘玩家之前所在格子和现在所在格子。但对于21×21的迷宫,全量重绘开销很小,不值得做增量绘制。如果以后扩大到101×101,才需要考虑用InvalidateRect指定脏矩形,MFC底层会自动合并多个脏区域,减少重绘范围。这个属于性能进阶,不做展开。
5. MFC迷宫游戏避坑指南:五个典型的翻车现场
这部分是整篇文章里最值钱的地方。以下每个问题我都亲手调试过,有些甚至折腾了一天,原因说出来简单,但排查过程真让人头大。
5.1 现象:画面闪烁,移动方向键像在放幻灯片
原因:直接在OnPaint里用CPaintDC绘制,没有使用双缓冲。每次Invalidate都会让系统先擦除背景,再调用OnPaint,擦除和绘制之间有一段空窗期,快速移动时就会闪。
解决:把绘制内容先画到内存DC,最后一次性贴回窗口。具体做法就是我在3.2节写的,创建一个兼容DC和兼容位图,用memDC绘制,最后BitBlt。另外一个细节:调用Invalidate时传FALSE,告诉系统不要擦除背景。如果配合双缓冲,擦不擦影响不大,但多写一个FALSE总是更稳妥。检查自己代码里是不是写了Invalidate(TRUE),是的话改掉。
5.2 现象:键盘消息不响应,按方向键没反应
原因:窗口没有焦点。在单文档视图里,一般窗口创建后会自动获得焦点,但如果视类没有重写OnKeyDown,消息会被对话框或主框架吃掉。如果窗口内还有其他控件,比如不小心加了一个编辑框,焦点默认在编辑框上,方向键就变成移动光标了。
解决:在主框架的OnCreate里调用GetActiveView()->SetFocus()确保视图获得焦点。或者在CMainFrame的PreTranslateMessage里强制把键盘消息派发给视图。最简单的排查方法是加一个TRACE或OutputDebugString调试输出,看看OnKeyDown到底进来没有。如果没进来,就去检查焦点。我自己的习惯是重写CMainFrame::OnActivateView,在视图被激活时把焦点交给它。这个属于MFC里常见的“焦点陷阱”。
5.3 现象:迷宫生成一半就停止,或者某块区域全是墙
原因:随机方向收集邻居时,只有一个方向判断条件写错了,例如把r > 0写成了r >= 0,导致访问数组下标-1,程序可能崩溃;或者没有正确标记visited,导致已经访问过的格子又被当作邻居,生成过程卡死。
解决:这种bug最坑的是它不一定每次都复现,因为随机数每次不同。建议先在generate开头把随机数种子固定成一个固定值,比如mt19937 rng(12345),然后跑二次,如果每次都在同一位置卡住,说明算法逻辑问题;如果卡的位置随机,说明是数据或邻居收集的问题。用调试器在生成循环里设条件断点,检查stack大小和当前格子坐标。另外一个常见错误是打通墙时的方向对应反了:打通当前格子的下墙,却把邻居格子的上墙消除了,还多消除了一面。这时迷宫会出现重叠墙,看起来像双线。
5.4 现象:窗口拉大或缩小时,迷宫画出来变形,墙线粗细不均
原因:OnPaint里把客户区宽度直接除以格子数当作m_cellSize,或者反过来,画迷宫时用了GetClientRect的宽高比例动态计算。一旦窗口尺寸改变,坐标浮点数被截断,墙线就歪了。
解决:把m_cellSize固定为常量,不做窗口自适应。迷宫尺寸21×21,格子大小20像素,总大小420像素,然后把迷宫居中绘制,偏移量用客户区减去总大小除以2。当窗口小于迷宫尺寸,就不可避免被裁剪,但不会出现墙线错位。如果你需要自适应,应该基于整数计算,比如用min(宽/列数, 高/行数)作为格子大小,同时确保它不小于一个最小值,再用偏移量居中。别在计算时混合浮点和整数,尽量用整数除法。
5.5 现象:每次运行迷宫都一样,或者第一次一样后面就不一样
原因:随机数种子没有初始化,或者用了时间种子但生成得太快,多个迷宫在同一个tick内生成,种子值相同。还有可能是迷宫生成代码在构造函数里调用,构造函数执行时time函数还没有刷新。
解决:把迷宫生成放到OnInitialUpdate或者窗口创建完成的时机调用,不要放在构造函数里。种子用random_device或者time(nullptr)但加上一个递增计数器混合,确保连续生成不同迷宫。我用的是std::mt19937 rng(std::random_device{}()),这行代码放在generate函数内部,每次生成时重新构造随机引擎,种子来自系统硬件熵,基本不会重复。如果你调试时需要复现某个迷宫,打印种子的值,下次改用固定种子。
6. 进阶:给迷宫加上自动求解和计时器,跑通一局完整体验
一个简单迷宫游戏能在电脑上跑到这一步,已经能证明你对MFC的消息和绘制流程有基础掌握了。但如果你想让完成度更高,我建议做三件事。
第一,用BFS做自动寻路。递归回溯生成的迷宫是完美迷宫,用广度优先搜索从玩家当前位置找一条到终点的最短路径。BFS代码不长,用一个队列存储待访问格子,一个数组记录每个格子是从哪个格子过来的,最后从终点回溯到起点,拿到的就是路线。把这个路线画成浅色小点,玩家按空格键就能看到提示,对初学者特别友好,也是检验迷宫是否生成正确的直观手段。
第二,加一个计时器和步数统计。在视类里重写OnCreate,然后SetTimer(1, 1000, nullptr),在WM_TIMER消息里给秒数加1,再Invalidate。游戏结束后移动步数直接统计tryMovePlayer返回true的次数。计时和步数同时显示在窗口标题栏上,用SetWindowText格式化字符串即可。这个做法比在面板里放静态文本控件方便,不需要管控件刷新。
第三,存档功能。把当前迷宫数组、玩家位置、计时和步数写到一个自定义二进制文件里。迷宫数组是unsigned char,21×21不过441字节,写起来很快。加一个菜单项“继续游戏”,读取文件后赋值给m_maze,再更新玩家状态。需要注意存档文件格式的版本号,防止以后修改数据格式时旧档读崩溃。这是保底功能,防止玩到一半有事退出,下次还得重新生成迷宫,心都凉了。
整个项目给我最大教训是:迷宫游戏听起来简单,但真正把消息处理、绘制、数据结构串起来时,任何一个环节的知识断层都会变成莫名其妙的bug。比如最严重的穿墙问题,最后发现是墙方向和位掩码定义没对齐。做这个游戏的过程中,我把MFC的OnPaint和键盘消息这两个最基础的东西彻底摸透了,后面再做图像处理或简单2D游戏,完全是在这上面添砖加瓦。如果你正打算找一个MFC练手项目,就从这个迷宫开始,不要嫌它简单,把每一步跑通,你收获的绝对不只是个玩具。希望帮到你。
本文还有配套的精品资源,点击获取