ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

用MFC开发迷宫游戏:从消息循环到GDI双缓冲的完整实践

用MFC开发迷宫游戏:从消息循环到GDI双缓冲的完整实践 简介一份基于MFC框架编写的简单迷宫游戏完整源码工程面向C与Windows程序设计初学者帮助理解MFC窗口应用与经典寻路算法的结合。工程包共45个文件压缩后约2.13MB以Visual C 6.0项目为主包含7个.h与6个.cpp源文件、工程配置.dsp/.dsw、菜单等资源.rc以及5张界面与迷宫修改步骤截图.png并附有可运行exe便于对照验证。已有378人浏览学习适合作为MFC入门练手项目。资源内除核心CPlayer、主视图与文档类代码外还涉及DFS/BFS路径搜索、二维数组表示迷宫、鼠标/键盘交互、边界错误处理等实现思路步骤图“步骤14”“修改迷宫-创建自己的迷宫”则能引导读者替换地图与调整难度。通过阅读源码与运行调试可以直观掌握窗口管理、视图/文档架构、事件映射与GDI绘制的完整流程为后续开发复杂游戏或工具打好基础。1. 用 MFC 做迷宫游戏从消息循环到逐像素绘制的一次完整练手很多人学 MFC 都是从对话框和按钮开始的点到为止之后就把框架丢到一边。但 MFC 真正区别于脚本或者 Web 开发的地方在于它的消息映射机制、设备上下文DC管理以及 Win32 窗口生命周期的控制。用 MFC 做一个简单的迷宫游戏恰好能把这几块全部串起来迷宫生成算法负责数据MFC 的视图类负责渲染键盘消息负责交互还要处理窗口重绘、内存 DC 双缓冲这类桌面应用绕不开的细节。这个标题里的“简单”两个字很诚实——不需要用到 OpenGL、不需要物理引擎、不需要网络和多线程。你只需要一个单文档视图SDI项目、一张二维数组地图和不到两百行核心代码就能跑出一个可以用方向键控制移动、实时判断是否到达出口的完整程序。如果你正在从“会写 MFC 对话框”迈向“能写视图类游戏逻辑”或者纯粹想验证自己对 GDI 绘图的掌握程度这个项目是性价比很高的一步。2. MFC 迷宫游戏的框架选型SDI 视图类为什么比对话框更合适2.1 先分清 MFC 的四大类程序类、框架窗口类、文档类、视图类MFC 的四大类在整个框架里各管一段CWinApp 是程序的入口和消息泵CFrameWnd 管主窗口的边框和菜单CDocument 管理数据CView 负责在窗口客户区里做渲染。迷宫游戏里地图数组和玩家位置属于数据归属 CDocument或直接挂在视图里项目简单时不少人直接挂在视图成员变量上而画格子、画角色、响应键盘这些事必须由 CView 完成。对话框程序CDialogEx也能做迷宫但有个很现实的麻烦对话框默认没有视图类你需要在 OnPaint或 OnEraseBkgnd里自绘全部内容同时处理键盘消息还得先设置焦点并重载 PreTranslateMessage。SDI 模板生成的 CView 天然支持键盘焦点和 WM_PAINT 流程OnDraw 在窗口大小变化、遮挡恢复、最小化还原时都会被系统自动调用省掉了一大半“手动刷新”的脏活。用 VS 创建项目时选择“MFC 应用程序”应用程序类型选“单文档”项目风格选“MFC 标准”视图基类选择“CView”其余保持默认。这样生成出来的代码骨架里窗口类有 OnDraw 虚函数视图类有 OnKeyDown 消息处理入口迷宫逻辑只需要填进这两个地方。2.2 迷宫的数据结构设计一维数组和二维数组的取舍迷宫地图通常用矩阵表达“墙”和“路”各用 0 和 1 区分。假如迷宫是 21 列乘 21 行直接声明一个int maze[21][21]是最直观的做法。但放在 MFC 视图类里我更习惯用一个一维数组加行列数来组织// 在 CMyMazeView.h 中 #define MAZE_WIDTH 21 #define MAZE_HEIGHT 21 class CMyMazeView : public CView { protected: int m_maze[MAZE_WIDTH * MAZE_HEIGHT]; // 墙0路1 int m_playerRow, m_playerCol; // 玩家当前位置行列 int m_exitRow, m_exitCol; // 出口位置 int m_cellSize; // 每个格子的像素边长 };用一维数组存迷宫核心目的是让“迷宫数据”和“绘制逻辑”解耦。你要判断第 i 行第 j 列是否是墙时直接写m_maze[i * MAZE_WIDTH j]。后续不管是把地图保存到文件还是把地图序列化成字符串传到别处一行for循环就能搞定不需要嵌套两层循环。初始化时把所有格子全部填 0全都是墙然后用迷宫生成算法把通路挖开。这样逆向来想会简单很多先假设整个迷宫是一块实心砖再用算法挖出通道。玩家起点默认放在左上角 (1,1)出口放在右下角 (MAZE_HEIGHT-2, MAZE_WIDTH-2)也就是最外圈墙的内侧。2.3 消息映射是 MFC 的命脉键盘消息怎么从窗口送到 OnKeyDownMFC 里一切交互都走消息映射宏。视图类要响应键盘只需要两处代码在类的头文件里声明消息处理函数然后在源文件的 BEGIN_MESSAGE_MAP 块里绑定。// CMyMazeView.h public: afx_msg void OnKeyDown(UINT nChar, UINT nRepCnt, UINT nFlags); // CMyMazeView.cpp BEGIN_MESSAGE_MAP(CMyMazeView, CView) ON_WM_KEYDOWN() END_MESSAGE_MAP() void CMyMazeView::OnKeyDown(UINT nChar, UINT nRepCnt, UINT nFlags) { int newRow m_playerRow; int newCol m_playerCol; switch (nChar) { case VK_UP: newRow--; break; case VK_DOWN: newRow; break; case VK_LEFT: newCol--; break; case VK_RIGHT: newCol; break; default: CView::OnKeyDown(nChar, nRepCnt, nFlags); return; } // 校验新位置是否合法不越界且不是墙 if (newRow 0 newRow MAZE_HEIGHT newCol 0 newCol MAZE_WIDTH m_maze[newRow * MAZE_WIDTH newCol] 1) { m_playerRow newRow; m_playerCol newCol; Invalidate(); // 触发重绘 } if (m_playerRow m_exitRow m_playerCol m_exitCol) { AfxMessageBox(_T(恭喜通关)); } CView::OnKeyDown(nChar, nRepCnt, nFlags); }这段逻辑有三点值得展开。第一m_playerRow和m_playerCol存的是“格子坐标”不是“像素坐标”所以移动时只做整数加减不需要关心当前窗口大小。第二撞墙判断发生在移动前位置从未落进墙里所以画面不会出现角色被“吞进墙里”的错位感。第三Invalidate()只是给窗口发送一条“你的客户区已失效”的消息真正绘制发生在下一次 WM_PAINT 到达时这比直接调用RedrawWindow更安全不会在消息处理中途触发重入绘制。新手容易漏掉的一点是视图类默认CView不持有输入焦点你会发现按键没反应。解决方案是在视图刚创建时调用SetFocus()或者在OnInitialUpdate里设置焦点。SDI 模板生成的视图一般在OnInitialUpdate之后会自动获得焦点但如果你后面加入了编辑框之类控件焦点被抢走之后就需要在OnActivateView里重新设置焦点。2.4 迷宫生成算法深度优先寻路DFS 回溯法最少代码实现迷宫生成算法有很多流派递归回溯DFS、随机 Prim、Kruskal 都能用。对“简单迷宫”来说递归回溯算法实现最直接把迷宫地图当作一个图从起点出发随机挑一个相邻且未访问的格子打穿墙壁走过去走不动就原路返回直到所有格子都被访问过。这个算法生成的迷宫通路曲折但必然存在一条从起点到终点的路径。常见的做法是用显式栈避免递归深度过大毕竟 21×21 的格子最坏情况下递归深度能到几百层虽然默认栈能扛住但显式栈更容易控制和打断。void CMyMazeView::GenerateMaze() { // 初始化全为墙 for (int i 0; i MAZE_WIDTH * MAZE_HEIGHT; i) m_maze[i] 0; // visited 数组记录格子是否被访问过 bool visited[MAZE_HEIGHT][MAZE_WIDTH] { false }; // 用栈模拟递归栈内放格子坐标 std::vectorstd::pairint, int stack; int startR 1, startC 1; visited[startR][startC] true; m_maze[startR * MAZE_WIDTH startC] 1; // 打通起点 stack.push_back({ startR, startC }); // 四个方向上、下、左、右 int dirR[4] { -2, 2, 0, 0 }; int dirC[4] { 0, 0, -2, 2 }; while (!stack.empty()) { auto [cr, cc] stack.back(); stack.pop_back(); // 收集当前格子周围未被访问的邻居间隔一格才是邻居 std::vectorint candidates; for (int d 0; d 4; d) { int nr cr dirR[d]; int nc cc dirC[d]; if (nr 0 nr MAZE_HEIGHT - 1 nc 0 nc MAZE_WIDTH - 1 !visited[nr][nc]) { candidates.push_back(d); } } if (!candidates.empty()) { // 当前格子还有未访问的邻居保留它在栈里稍后还有可能回到这里 stack.push_back({ cr, cc }); // 随机选一个方向 int pick candidates[rand() % candidates.size()]; int wallR cr dirR[pick] / 2; int wallC cc dirC[pick] / 2; // 打穿中间的墙和邻居格子 m_maze[wallR * MAZE_WIDTH wallC] 1; int nr cr dirR[pick]; int nc cc dirC[pick]; m_maze[nr * MAZE_WIDTH nc] 1; visited[nr][nc] true; stack.push_back({ nr, nc }); } } // 设置出口和入口 m_playerRow 1; m_playerCol 1; m_exitRow MAZE_HEIGHT - 2; m_exitCol MAZE_WIDTH - 2; m_maze[m_exitRow * MAZE_WIDTH m_exitCol] 1; // 确保出口是路 }这段代码里有几个关键参数很容易写错。方向数组用的是-2, 2而不是-1, 1因为迷宫生成是“隔一个格子打穿一堵墙”——当前格子的邻居是距离两格的格子它俩中间夹着的才是墙。如果方向步长设成 1算法就变成了普通寻路生成的不是迷宫而是树状的稀疏通路。另外stack.push_back({ cr, cc })这一行不能漏当前格子周围有未访问邻居时必须把它留在栈里否则走到死路时无法回溯最终迷宫会有大量格子访问不到。随机数生成应该初始化种子。在OnInitialUpdate里调用一次srand((unsigned)time(NULL))不然每次启动程序看到的迷宫一模一样。这样做的代价是每次运行迷宫都不同如果你希望某个迷宫可以复现可以将seed作为参数保存下来下次用相同种子重新生成一遍。这个细节在后文的存档功能里会用到。3. 在 MFC 视图类里绘制迷宫GDI 核心操作与双缓冲3.1 认识绘图三要素DC、画刷、像素坐标换算MFC 的绘制工作离不开设备上下文DC。视图类的OnDraw函数会收到一个CDC* pDC参数它代表了窗口客户区的“画布”。所有绘制行为都要通过它来完成比如pDC-Rectangle()画矩形框pDC-FillSolidRect()填充颜色块pDC-TextOutW()输出文字。迷宫游戏里格子与像素的换算关系是绘制的基础。假如每个格子画成 30×30 像素那么格子 (row, col) 的左上角像素坐标就是 (col * 30, row * 30)。这个换算逻辑一定要单独写函数否则你在绘制代码里到处写乘法和加法后面想改格子大小时就得翻遍所有绘制逻辑CRect CMyMazeView::GetCellRect(int row, int col) const { return CRect( col * m_cellSize, row * m_cellSize, (col 1) * m_cellSize, (row 1) * m_cellSize ); }绘制流程本身很简单遍历所有格子墙就用深色填充路就用浅色填充最后把玩家画成一个小圆——用Ellipse配合一个颜色鲜明的画刷即可。这是整个项目里最容易翻车的地方窗口拉大缩小时格子大小是固定的 30 像素迷宫地图整体偏在窗口左上角右边和下边露出一大块空白。解决方法是把m_cellSize设计成可计算的动态值这部分在第 6 章里展开。3.2 双缓冲绘制消灭迷宫刷新闪烁的玄学如果不做双缓冲直接在OnDraw里逐格FillSolidRect你会看到画面闪烁得厉害——尤其按住方向键连续移动时整个窗口像在“高频振动”。原因是每次Invalidate()触发重绘时窗口先擦掉背景默认填充白色再逐格重画迷宫。擦和画之间有几十毫秒的时间差人眼就会感知到闪烁。解决思路是把绘制动作先在内存里完成再把内存整块拷贝到窗口。MFC 里常见做法是构造一个内存 DC并在其上创建一个兼容位图void CMyMazeView::OnDraw(CDC* pDC) { // 创建内存 DC兼容窗口 DC CDC memDC; memDC.CreateCompatibleDC(pDC); // 创建一个与客户区大小匹配的位图 CBitmap bmp; bmp.CreateCompatibleBitmap(pDC, m_winWidth, m_winHeight); CBitmap* pOldBmp memDC.SelectObject(bmp); // 先清空背景 memDC.FillSolidRect(0, 0, m_winWidth, m_winHeight, RGB(24, 24, 24)); // 绘制迷宫全部格子 for (int r 0; r MAZE_HEIGHT; r) { for (int c 0; c MAZE_WIDTH; c) { CRect rc GetCellRect(r, c); if (m_maze[r * MAZE_WIDTH c] 1) memDC.FillSolidRect(rc, RGB(255, 255, 255)); // 路 else memDC.FillSolidRect(rc, RGB(40, 40, 120)); // 墙 } } // 绘制玩家蓝色圆点 CRect playerRc GetCellRect(m_playerRow, m_playerCol); playerRc.DeflateRect(m_cellSize / 4, m_cellSize / 4); memDC.FillSolidRect(playerRc, RGB(255, 120, 0)); // 把内存画面整体拷贝到窗口 pDC-BitBlt(0, 0, m_winWidth, m_winHeight, memDC, 0, 0, SRCCOPY); // 清理选回旧位图释放对象 memDC.SelectObject(pOldBmp); }这段绘制代码里CreateCompatibleBitmap的宽高如果不匹配窗口客户区会出现画面被裁剪或右侧出现花屏。m_winWidth和m_winHeight应该在OnSize消息里同步更新。SelectObject选入位图后绘制结束必须选回旧位图否则位图对象在析构时可能引发 GDI 对象泄漏——MFC 调试模式下会直接报warning: CDialogEx之类的内存泄漏提示。这里还要提一点OnEraseBkgnd默认会擦背景双缓冲之后背景擦除显得多余且会增加闪烁。重载OnEraseBkgnd直接返回TRUE表示已处理擦除把擦除动作合并到内存 DC 的FillSolidRect里这是 MFC 游戏绘制的标准做法能让画面刷新更加平滑。3.3 窗口尺寸自适应把迷宫显示在客户区中央迷宫地图是 21×21格子 30 像素时地图只有 630×630。窗口比地图大时地图贴左上角比地图小时地图被裁剪——两种都不好看。一个更讲究的实现是根据客户区大小反推格子尺寸再把地图居中void CMyMazeView::OnSize(UINT nType, int cx, int cy) { CView::OnSize(nType, cx, cy); // 计算最大能容纳的格子尺寸 int cellW cx / MAZE_WIDTH; int cellH cy / MAZE_HEIGHT; m_cellSize min(cellW, cellH); // 保证最小可见尺寸 if (m_cellSize 10) m_cellSize 10; // 计算地图显示区域的左上角偏移用于居中 m_offsetX (cx - MAZE_WIDTH * m_cellSize) / 2; m_offsetY (cy - MAZE_HEIGHT * m_cellSize) / 2; m_winWidth cx; m_winHeight cy; }对应的GetCellRect需要把偏移代入CRect GetCellRect(int row, int col) const { return CRect( m_offsetX col * m_cellSize, m_offsetY row * m_cellSize, m_offsetX (col 1) * m_cellSize, m_offsetY (row 1) * m_cellSize ); }这个细节能让窗口拉伸时迷宫始终完整可见、位置居中视觉上专业很多。坑点在于OnSize在窗口创建早期就会触发一次此时视图可能还没完全初始化如果m_offsetX还没初始化会读到随机值。解决办法是在视图构造函数里初始化所有成员变量后再处理OnSize。4. 迷宫寻路验证如何确认生成的迷宫一定有解并找到最短路径4.1 BFS 遍历验证迷宫连通性迷宫生成算法理论上保证所有格子连通但实际工程里数组越界、方向数组笔误、出口被墙包围这些 bug 都可能出现。写一个 BFS 验证函数在生成迷宫之后立刻检查入口和出口是否连通是减少返工的好习惯——血泪经验是你永远不要指望自己的算法一次写对。bool CMyMazeView::IsMazeSolvable() { bool visited[MAZE_HEIGHT][MAZE_WIDTH] { false }; std::queuestd::pairint, int q; visited[m_playerRow][m_playerCol] true; q.push({ m_playerRow, m_playerCol }); int dirR[4] { -1, 1, 0, 0 }; int dirC[4] { 0, 0, -1, 1 }; while (!q.empty()) { auto [r, c] q.front(); q.pop(); if (r m_exitRow c m_exitCol) return true; for (int d 0; d 4; d) { int nr r dirR[d]; int nc c dirC[d]; if (nr 0 nr MAZE_HEIGHT nc 0 nc MAZE_WIDTH !visited[nr][nc] m_maze[nr * MAZE_WIDTH nc] 1) { visited[nr][nc] true; q.push({ nr, nc }); } } } return false; }在GenerateMaze()的结尾调用一次这个函数如果返回false直接AfxMessageBox报错提示开发期异常。这个验证函数本身不消耗太多性能21×21 的迷宫 BFS 在毫秒级以内就能跑完。4.2 显示最短路径在 BFS 基础上记录父节点BFS 除了判断连通性还能顺便输出一条从入口到出口的最短路径。做法是增加一个parent数组记录每个格子的前驱格子。玩家到达出口后沿着 parent 反推就能画出一条完整的路径线。std::vectorstd::pairint, int CMyMazeView::FindShortestPath() { std::vectorstd::pairint, int path; int parent[MAZE_HEIGHT][MAZE_WIDTH][2]; bool visited[MAZE_HEIGHT][MAZE_WIDTH] { false }; std::queuestd::pairint, int q; memset(parent, -1, sizeof(parent)); visited[m_playerRow][m_playerCol] true; q.push({ m_playerRow, m_playerCol }); int dirR[4] { -1, 1, 0, 0 }; int dirC[4] { 0, 0, -1, 1 }; bool found false; while (!q.empty() !found) { auto [r, c] q.front(); q.pop(); for (int d 0; d 4; d) { int nr r dirR[d]; int nc c dirC[d]; if (nr 0 nr MAZE_HEIGHT nc 0 nc MAZE_WIDTH !visited[nr][nc] m_maze[nr * MAZE_WIDTH nc] 1) { visited[nr][nc] true; parent[nr][nc][0] r; parent[nr][nc][1] c; if (nr m_exitRow nc m_exitCol) { found true; break; } q.push({ nr, nc }); } } } if (found) { int r m_exitRow, c m_exitCol; while (!(r m_playerRow c m_playerCol)) { path.push_back({ r, c }); int pr parent[r][c][0]; int pc parent[r][c][1]; r pr; c pc; } path.push_back({ m_playerRow, m_playerCol }); std::reverse(path.begin(), path.end()); } return path; }绘制时用MoveTo和LineTo依次连接路径上的每个格子中心点。这条路径可以作为“提示”功能加入菜单——玩家卡关时按 F2 显示最短路径辅助线再次按 F2 隐藏。这比单纯做一个能走通的迷宫更有教学价值迷宫生成、路径搜索、GDI 折线绘制三件事一起练了。注意parent数组用memset初始化为 -1 时memset(parent, -1, sizeof(parent))对int数组是安全的因为 -1 的补码表示是全 F逐字节填充后每个 int 仍然是 -1。但如果数组是bool类型memset填 -1 会变成true这是新手容易踩的一个隐蔽坑。4.3 走迷宫实现手动控制与自动寻路对比手动控制交给OnKeyDown自动寻路则需要一个定时器。SetTimer(1, 100, NULL)每 100 毫秒触发一次OnTimer在定时器里沿着FindShortestPath的路径移动一步玩家就能自动走向出口。自动寻路作为演示功能放在菜单或右键菜单里能直观对比“手动走迷宫”和“算法解迷宫”的区别。void CMyMazeView::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent 1 !m_autoPath.empty()) { // 每次移动一格 auto next m_autoPath[m_autoStepIndex]; m_playerRow next.first; m_playerCol next.second; m_autoStepIndex; if (m_autoStepIndex (int)m_autoPath.size()) { KillTimer(1); AfxMessageBox(_T(自动寻路完成)); } Invalidate(); } CView::OnTimer(nIDEvent); }这里有个隐藏问题如果玩家在自动寻路过程中手动按方向键角色位置会被手动修改而自动路径的索引还在继续走两者冲突导致角色瞬移。解决方式是按下方向键时取消定时器case VK_UP: KillTimer(1); // ... 原有移动逻辑5. 常见问题与避坑MFC 迷宫开发里的五个典型翻车点5.1 字符集不一致导致编译错误或中文乱码现象代码里写恭喜通关编译报错或者消息框标题乱码。原因MFC 项目在新版 VS 里默认使用 Unicode 字符集字符串字面量是wchar_t*类型直接赋值给CString需要_T()宏包裹。如果项目被改成了多字节字符集情况又会反过来。解决所有字符串统一用_T()包裹项目属性里字符集选择“使用 Unicode 字符集”。记事本写代码并粘贴到 VS 的场合尤其容易出问题——文件编码如果不是 UTF-8 with BOM中文注释也会产生乱码但那是编辑器问题跟 MFC 本身无关。5.2 CString 内存泄漏调试输出里刷屏的{n} blk(s) still allocated现象关闭程序时 VS 输出窗口显示大量内存泄漏报告看调用栈几乎都指向 CString 相关代码。原因在循环体里频繁构造临时CString尤其是字符串拼接场景每一次都会触发内存分配。如果这些临时对象在消息循环里反复创建销毁调试版的DEBUG_NEW会记录到泄漏。严格来说 MFC 的 CString 在程序退出时会统一释放被报告为“泄漏”往往是没有调_CrtDumpMemoryLeaks()的时机问题但也可能是你的 CString 生命周期管理不干净。解决迷宫游戏里几乎不该出现 CString 高频拼接。如果要在OnDraw里显示“步数”之类的文本用CString str; str.Format(_T(步数: %d), m_steps);避免循环内拼接。另外一个经验是不要用GetBuffer后忘记ReleaseBuffer——一旦调用了GetBuffer修改完必须立刻ReleaseBuffer否则字符串长度信息会错乱严重时直接崩溃。5.3 键盘消息失效方向键完全没反应现象程序运行后方向键按了没反应但鼠标点击菜单正常。原因视图窗口没有焦点键盘消息被送到了主框架窗口而不是视图。SDI 程序里主框架窗口是焦点拥有者键盘消息默认发给了框架类或者你在界面上加了一个按钮控件按钮抢走了焦点。解决在OnInitialUpdate里调用SetFocus()如果视图类需要响应OnActivateView在激活时也调用一次。这里有个更容易犯的错你重载了OnKeyDown但忘记在BEGIN_MESSAGE_MAP里添加ON_WM_KEYDOWN()。消息映射宏漏一行函数写得再对也不会被调用。检查顺序头文件里有afx_msg声明源文件消息映射里有ON_WM_KEYDOWN()函数实现体正确——三处缺一不可。5.4 重绘闪烁即使做了双缓冲仍然闪现象按住方向键画面还是闪烁或出现残影。原因双缓冲只解决了WM_PAINT里的闪烁。如果OnEraseBkgnd没有重载窗口默认会先擦背景再进入OnDraw背景擦除是白闪的根源。还有一个隐蔽情况是你在OnKeyDown里同时调用了Invalidate()和UpdateWindow()后者强制同步重绘导致移动消息和重绘在同一个线程里频繁嵌套。解决重载OnEraseBkgnd直接return TRUE;。另外OnKeyDown里只调Invalidate()让系统在空闲时统一重绘不要手动UpdateWindow()。如果你有多处代码触发重绘移动、定时器、窗口大小变化统一收口到一个RefreshMaze()方法里方法内部只做一件事更新数据和调用Invalidate()。5.5 迷宫数据在调试与 Release 表现不一致现象Debug 版迷宫生成正常Release 版迷宫生成一半直接崩溃或者迷宫形状不一样。原因常见原因是rand()在 Debug 和 Release 下的随机序列不同这属于预期但更危险的是未初始化成员变量。Debug 版未初始化内存通常被系统填成 0xCC恰好让逻辑走通Release 版未初始化内存是随机垃圾数组访问越界当场崩溃。解决在视图构造函数里用初始化列表或构造函数体把所有成员变量全部初始化不要依赖“默认值”。检查是否所有路径分支都初始化了m_cellSize、m_offsetX、m_offsetY——OnSize在窗口创建前不会调用所以构造函数里先给一份缺省值。6. 进阶玩法迷宫规模动态调整、地图保存与成绩计时迷宫做出来之后向“可玩”方向扩展有三个低成本增强迷宫规模可选、地图保存/读取、通关计时。这三个功能没有引入新概念只是对现有代码做参数化和持久化处理。在菜单“游戏”下加一个对话框用编辑框接收迷宫宽度和高度建议限制在 11~61 之间的奇数确定后重新生成迷宫并重置玩家位置。需要注意奇数的限制——迷宫生成算法里“隔一个格子打穿墙”的设计要求宽高都是奇数否则边界格子会被孤立生成结果可能不是完整迷宫。校验不通过时直接弹提示不强行修正。地图保存用简单文本格式即可第一行写宽高第二行写玩家坐标和出口坐标第三行开始逐行写迷宫数据。用 CFile 或 std::ofstream 都行。这里有个小坑保存时迷宫数组用十六进制文本写出来比十进制更易读但读回时别忘记按对应进制解析。推荐直接用空格分隔的 0/1 序列简单可靠文件小到可以忽略。计时功能通过GetTickCount64()实现游戏开始时记录起始时间通关时相减得到毫秒数换算成“分:秒”显示在消息框里。这个计时器不受窗口重绘影响准确度足够游戏使用。如果对实时显示有要求可以加一个定时器每秒刷新状态栏或直接绘制在视图右上角。把迷宫难度做成参数后格子大小自动计算就更有意义了——61×61 的迷宫在一个 800×600 窗口里每格不足 10 像素路径几乎看不清。我一般会把最小格子尺寸限制在 8 像素低于这个值直接提示用户放大窗口或减小规模。最终代码跑通透之后这个项目留给你的不只是迷宫本身而是一套“数据 渲染 交互 验证”的完整桌面应用套路这套套路换十个项目都还用得上。希望这些参数和踩坑记录能帮你在复现时少走几趟弯路。本文还有配套的精品资源点击获取
返回列表