资讯详情

MFC GDI 迷宫游戏实战:双缓冲、BFS寻路与二次开发

📅 2026/11/2 8:59:21 | 华诺云谱 👁 阅读
MFC GDI 迷宫游戏实战:双缓冲、BFS寻路与二次开发
简介一份基于 MFC 的简单迷宫游戏完整工程面向希望入门 Windows 桌面开发与 C 游戏逻辑的初学者及课程设计参考者重点解决用 MFC 封装窗口、视图和交互来搭建小型图形游戏的问题。压缩包共 45 个文件、约 2.13MB包含 7 个 h 头文件、6 个 cpp 源文件以及 obj、pdb、ilk 等编译中间文件png 步骤图与 bmp 迷宫素材ico 图标、rc 资源脚本和 dsw/dsp 工程文件并附带可运行 exe 与 ReadMe 说明。已有 379 人学习适合对照 VC6 工程结构逐层阅读。源码采用文档/视图架构player 类承载玩家状态View 类负责迷宫绘制与鼠标键盘响应资源图展示了修改迷宫和创建自定义迷宫的步骤读者可据此理解二维数组存储地图、GDI 画笔刷绘制、事件映射以及 DFS/BFS 路径搜索思路并直接编译运行验证积累 MFC 项目调试与改造经验。1. 带新人练 GDI我一般直接丢一个 MFC 迷宫游戏过去带新人练 Windows 绘图我很少推荐教材里那些画线画圆的例子而是直接给一个用 MFC 写的简单迷宫游戏对话框工程源码就几个文件不依赖任何第三方库编译出来一个几百 KB 的 exe方向键走格子走到右下角弹出通关提示再点一下重开一局。它小到能一口气读完又差不多把该有的东西都装进去了——消息映射、定时器、双缓冲绘制、BFS 寻路一个桌面小游戏该有的骨架它都有。适合两类人刚学完 C 语法、想找个能真正跑起来的 MFC 游戏练手的以及手上有个课程设计或小工具想拿它当底稿往上加关卡、加道具、加计分榜的。2. 工程骨架拆解从 CWinApp 到对话框的消息流向这是个典型的基于对话框的 MFC 工程不是文档/视图结构。选择对话框的理由很实在不用建 Document、View、Frame 一整套窗口本身就是画布OnPaint里拿到CPaintDC直接开画几千行以内的小游戏这样写最省事。代价是后面想加菜单栏、工具栏、多文档切换时会别扭但一个迷宫游戏压根用不到那些。先把工程的骨架摸清楚再谈算法。骨架不清楚改代码全是瞎猜。2.1 目录与文件清单一个对话框工程的组成部分新建工程时选「MFC 应用」类型选「基于对话框」生成的文件不多各管一摊文件作用改动频率Maze.cpp/Maze.hCWinApp派生类进程入口InitInstance几乎不改MazeDlg.cpp/MazeDlg.h主对话框类全部游戏逻辑都在这主要改动集中在这里Maze.rc/resource.h对话框模板、控件 ID、图标资源加控件时才动framework.h/pch.h预编译头MFC 头文件集中在这换 VS 版本时才动Maze.vcxproj工程配置字符集、MFC 链接方式、平台工具集首次配置时定死.rc是文本格式可以直接用记事本改但建议还是用资源编辑器手改容易把控件 ID 改成重号resource.h里的宏冲突了报错信息会很难懂。工程属性里有三处必须确认配置属性 → 高级 → 字符集选「使用 Unicode 字符集」配置属性 → 常规 → MFC 的使用选「在共享 DLL 中使用 MFC」想发给别人单文件运行就改「在静态库中使用 MFC」exe 会大几 MB 但不用装运行库链接器 → 系统 → 子系统必须是「窗口」选成控制台会多弹一个黑窗。CWinApp派生类短得可怜但有一行写错就跑不起来BOOL CMazeApp::InitInstance() { // 高 DPI 机器上不声明 DPI 感知对话框会被系统拉伸成模糊的一团 CWinApp::InitInstance(); CMazeDlg dlg; m_pMainWnd dlg; dlg.DoModal(); // 模态对话框用户关窗后 DoModal 才返回 return FALSE; // 对话框程序固定返回 FALSEMFC 不进 Run() 消息循环 }m_pMainWnd必须赋值否则 MFC 某些内部逻辑找不到主窗口return FALSE是对话框程序的固定写法返回TRUE的话InitInstance之后还会进CWinApp::Run()而那时对话框已经销毁程序行为会很怪。这段代码基本是一次性写好的之后所有工作都在CMazeDlg里。2.2 三个核心类的职责边界谁该干什么工程里其实只有三类东西应用类、对话框类、数据层。数据层没有单独封类就是一个std::vectorBYTE加几个成员变量全塞在CMazeDlg里。这种规模的项目硬拆出CMazeModel、CMazeRenderer反而是过度设计——但心里要有一条线CMazeDlg里凡是碰CDC的代码都属于「渲染」凡是碰m_grid的都属于「数据」两者不要互相调用对方的东西。我一般会把成员函数分成三组摆放方便日后找人接手class CMazeDlg : public CDialogEx { // ... 生成与数据 void GenerateMaze(int nRows, int nCols); bool InBounds(const CPoint pt) const; bool IsWall(const CPoint pt) const; // ... 渲染 void DrawGrid(CDC dc); void DrawPlayer(CDC dc); void DrawHint(CDC dc); // ... 流程 void ResetGame(); void OnWin(); void UpdateStatus(); };职责分清楚了改渲染不会碰到生成逻辑改生成逻辑也不用担心画面。最常见的反面写法是把生成、绘制、判定全塞进按钮响应函数里一百多行改一个格子尺寸要找半天。2.3 消息映射表键盘、定时器和重绘是怎么进来的MFC 的消息映射表是整个程序的事件总闸迷宫游戏需要挂的就这么几条BEGIN_MESSAGE_MAP(CMazeDlg, CDialogEx) ON_WM_PAINT() // 窗口需要重绘 ON_WM_KEYDOWN() // 方向键 ON_WM_TIMER() // 计时器每秒跳一次 ON_WM_ERASEBKGND() // 拦截背景擦除防闪烁 ON_WM_GETDLGCODE() // 声明我要自己吃方向键 END_MESSAGE_MAP()注意ON_WM_ERASEBKGND和ON_WM_GETDLGCODE这两条很多照着教程写出来的迷宫游戏画面闪、方向键没反应根子都在这。前者默认行为是先用背景刷把客户区刷成白色再调OnPaint画内容两次绘制之间就是肉眼可见的白闪后者是对话框默认把方向键当成控件间的焦点切换你的OnKeyDown根本收不到VK_LEFT。两条响应函数都只有一行但作用关键BOOL CMazeDlg::OnEraseBkgnd(CDC* pDC) { return TRUE; // 告诉系统背景我自己负责OnPaint 里 BitBlt 会全量覆盖 } UINT CMazeDlg::OnGetDlgCode() { return DLGC_WANTARROWS | DLGC_WANTCHARS; // 方向键和字符键都交给我 }OnEraseBkgnd返回TRUE表示已处理系统不再擦背景。前提是你的OnPaint确实把整个客户区都覆盖了一遍——用双缓冲BitBlt整块贴图就满足这个前提如果只在脏矩形里画几个格子残留的旧像素会让你怀疑人生。OnGetDlgCode返回DLGC_WANTARROWS之后对话框的默认焦点导航就被绕开了。这条是踩坑高发区第 5 章还会再提一次。3. 迷宫生成与渲染一个 21×21 网格从空白到可走通骨架搭好接下来是最有意思的部分怎么让一堆墙变成一座能走通的迷宫又怎么把它画得不像上个世纪的程序。这两件事在代码上其实是分开的先生成数据再画数据中间不该有耦合。3.1 数据结构一维 vector 存二维网格网格用一维std::vectorBYTE存索引公式y * m_nCols x。这是很常见的做法比CArrayCPoint, CPoint或者vectorvectorBYTE都省事内存连续、assign一行就能重置、传给算法不用考虑嵌套容器的迭代器失效。用BYTE而不是bool是因为vectorbool是位压缩的特化取不到元素地址写起来别扭用BYTE存 0/1 浪费的那点内存101×101 也就 10KB不值一提。成员变量大致这么几个看一遍就知道整个游戏的状态都在这了成员类型含义常用取值m_gridstd::vectorBYTE一维展开的网格0 是墙1 是路长度 行×列m_nRows/m_nColsint行列数必须是奇数21 / 21m_nCellSizeint单格像素边长24m_ptPlayerCPoint玩家所在格x 是列、y 是行起点 (1, 1)m_ptExitCPoint出口所在格(cols-2, rows-2)m_nSteps/m_nSecondsint步数与用时从 0 开始m_bWinbool通关标志防重复弹窗false行列必须是奇数是这套算法的硬约束。迷宫用「墙-路-墙-路」交替的棋盘格结构偶数尺寸会让最右一列或最下一行的墙对不齐生成出来边上会漏一条缝玩家能从外面绕出去。3.2 深度优先回溯生成从全墙到连通迷宫算法本身是标准的 DFS 回溯从 (1,1) 出发每次随机挑一个「隔一格」的邻居如果那格还是墙就把中间那堵墙打通走过去走不动了就退栈。void CMazeDlg::GenerateMaze(int nRows, int nCols) { // 强制奇数偶数尺寸会让墙-路交替的网格在最右/最下错位 m_nRows nRows | 1; m_nCols nCols | 1; m_grid.assign(m_nRows * m_nCols, 0); // 0 墙 // 手写栈代替递归101x101 时递归深度上千栈溢出不是玩笑 std::vectorCPoint stack; CPoint start(1, 1); m_grid[start.y * m_nCols start.x] 1; stack.push_back(start); const int dx[4] { 2, 0, -2, 0 }; // 跨两格中间那格是要打通的墙 const int dy[4] { 0, 2, 0, -2 }; while (!stack.empty()) { CPoint cur stack.back(); // 洗牌方向顺序否则每局迷宫长得一模一样 int order[4] { 0, 1, 2, 3 }; for (int i 3; i 0; --i) std::swap(order[i], order[rand() % (i 1)]); bool bAdvanced false; for (int k 0; k 4; k) { int nx cur.x dx[order[k]]; int ny cur.y dy[order[k]]; // 边界留一格余量nx 最大只能到 m_nCols - 2 if (nx 0 || ny 0 || nx m_nCols - 1 || ny m_nRows - 1) continue; if (m_grid[ny * m_nCols nx] 1) continue; // 已经访问过 // 打通 cur 和 (nx,ny) 之间那堵墙 int wx cur.x dx[order[k]] / 2; int wy cur.y dy[order[k]] / 2; m_grid[wy * m_nCols wx] 1; m_grid[ny * m_nCols nx] 1; stack.push_back(CPoint(nx, ny)); bAdvanced true; break; // 贪心走到底DFS 的精髓在这 } if (!bAdvanced) stack.pop_back(); // 四方向都试过回退 } m_ptExit CPoint(m_nCols - 2, m_nRows - 2); }几个参数值得说清楚dx/dy的步长是 2 而不是 1因为要跳过中间那堵墙写成 1 的话相邻两格都变成路迷宫就退化成空旷的房间了。边界判断用 0和 m_nCols - 1而不是0/m_nCols是为了保证最外圈永远留一圈墙否则打通到边缘时下标会越界vector在 Debug 下直接弹断言Release 下就是无声的内存破坏。这个算法生成的是「完美迷宫」任意两点之间有且仅有一条路径没有环。这个性质正好解释了为什么第四章的 BFS 结果就是唯一解——没有替代路线最短路径即唯一路径。想要更开阔的多路径迷宫可以再随机打通一批墙但那样激进度要控制打通太多就成了一片空地。rand()记得在OnInitDialog里srand((unsigned)time(nullptr))否则每局迷宫完全一样第一次玩还行第三次就发现规律了。3.3 双缓冲绘制把闪烁彻底解决在 OnPaint 里直接往CPaintDC上画格子格子一多就会闪因为每一笔都是直接怼到屏幕用户能看到中间过程。标准解法是内存 DC在内存里把整幅画面画完再一次BitBlt贴到屏幕。void CMazeDlg::OnPaint() { CPaintDC dc(this); CRect rcClient; GetClientRect(rcClient); // 内存 DC 和兼容位图所有绘制先在内存完成 CDC memDC; memDC.CreateCompatibleDC(dc); CBitmap bmp; bmp.CreateCompatibleBitmap(dc, rcClient.Width(), rcClient.Height()); CBitmap* pOldBmp memDC.SelectObject(bmp); // 兜底铺底窗口拉大时格子覆盖不到的区域不能是脏数据 memDC.FillSolidRect(rcClient, RGB(250, 250, 250)); CBrush brWall(RGB(40, 44, 52)); CBrush brRoad(RGB(255, 255, 255)); for (int y 0; y m_nRows; y) { for (int x 0; x m_nCols; x) { CRect rcCell(x * m_nCellSize, y * m_nCellSize, (x 1) * m_nCellSize, (y 1) * m_nCellSize); CBrush* pBr (m_grid[y * m_nCols x] 0) ? brWall : brRoad; memDC.FillRect(rcCell, pBr); } } DrawPlayer(memDC); // 玩家和出口画在格子之上 DrawHint(memDC); // 提示路径没有提示时函数内部直接返回 // 一次性贴到屏幕用户看到的是完整的一帧 dc.BitBlt(0, 0, rcClient.Width(), rcClient.Height(), memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBmp); // 必须还原否则位图析构时还被选着会断言 }CreateCompatibleBitmap拿的是CPaintDC的格式别传memDC自己新建的内存 DC 默认是 1×1 单色位图拿它做模板得到的位图画出来是黑白的这个坑见一次记一辈子。SelectObject之后一定要还原旧的 GDI 对象CBitmap析构时如果还挂在 DC 上MFC 在 Debug 下会抛断言Release 下就是资源泄漏。还有个小优化OnPaint里别调GetClientRect之外的任何窗口操作也别在里面Invalidate会自己触发自己卡死。3.4 玩家状态与移动判定一次按键发生的四件事方向键进来后只做四件事算出目标格、判断边界、判断是不是墙、更新状态并请求重绘。void CMazeDlg::OnKeyDown(UINT nChar, UINT nRepCnt, UINT nFlags) { if (m_bWin) // 已通关按键不再响应 { CDialogEx::OnKeyDown(nChar, nRepCnt, nFlags); return; } CPoint next m_ptPlayer; switch (nChar) { case VK_UP: next.y - 1; break; case VK_DOWN: next.y 1; break; case VK_LEFT: next.x - 1; break; case VK_RIGHT: next.x 1; break; default: CDialogEx::OnKeyDown(nChar, nRepCnt, nFlags); return; } if (!InBounds(next) || IsWall(next)) { MessageBeep(MB_ICONWARNING); // 撞墙给个听觉反馈成本最低 return; } m_ptPlayer next; m_nSteps; if (m_ptPlayer m_ptExit) OnWin(); Invalidate(FALSE); // 只标脏让系统合并重绘别在这里 UpdateWindow 硬刷 }Invalidate(FALSE)的参数是不擦背景配合前面OnEraseBkgnd返回TRUE重绘时不会再闪。写成Invalidate(TRUE)或者加上UpdateWindow()画面就会一闪一闪按键连按的时候尤其明显。InBounds和IsWall两个判断分开写是因为前者防越界、后者防逻辑错误合成一个函数以后出问题不好定位。m_ptPlayer m_ptExit用的是CPoint重载的比较 x 和 y没问题。4. 自动寻路与胜负判定BFS 最短路径怎么接进游戏循环迷宫能走通之后玩家很快就会腻或者卡在死胡同里出不来。加一个「提示下一步」的功能是这类小项目最常见的二次开发需求也是最自然的算法练习——网格迷宫就是一张无权图求最短路径用 BFS 就够了不用上 A*。4.1 BFS 最短路径迷宫即无权图每个可走格子是图上的一个节点相邻可走格子之间有一条边。BFS 按层扩展第一次到达目标时的路径必然最短。std::vectorCPoint CMazeDlg::FindPath(CPoint from, CPoint to) { const int nTotal m_nRows * m_nCols; std::vectorint prev(nTotal, -1); // 前驱数组-1 表示未访问 std::queueint q; const int nStart from.y * m_nCols from.x; const int nGoal to.y * m_nCols to.x; prev[nStart] nStart; // 起点自指回溯时用来终止 q.push(nStart); const int dx[4] { 1, -1, 0, 0 }; const int dy[4] { 0, 0, 1, -1 }; while (!q.empty()) { int cur q.front(); q.pop(); if (cur nGoal) break; int cx cur % m_nCols, cy cur / m_nCols; for (int i 0; i 4; i) { int nx cx dx[i], ny cy dy[i]; if (nx 0 || ny 0 || nx m_nCols || ny m_nRows) continue; int nNext ny * m_nCols nx; if (prev[nNext] ! -1) continue; // 已访问BFS 不重复入队 if (m_grid[nNext] 0) continue; // 撞墙 prev[nNext] cur; // 记录从哪来路径靠它回溯 q.push(nNext); } } std::vectorCPoint path; if (prev[nGoal] -1) return path; // 不连通返回空 for (int node nGoal; node ! nStart; node prev[node]) path.push_back(CPoint(node % m_nCols, node / m_nCols)); std::reverse(path.begin(), path.end()); return path; }复杂度是 O(行×列)21×21 的迷宫四百多个格子一毫秒都不到随便什么时候调都不用心疼。返回值里不包含起点、包含终点调用方从索引 0 开始取下一格就行。一个容易忽略的点队列里存的是int索引而不是CPoint因为prev数组已经是索引制来回转换反而容易写错。prev[nStart] nStart这个自指很关键回溯循环靠它终止写成-1就会死循环或者访问越界。4.2 提示路径的绘制用 CPen 画一条折线有了路径画出来就是几行的事。折线宽度 3 像素颜色跟玩家区分开。void CMazeDlg::DrawHint(CDC dc) { if (!m_bShowHint) // 没点提示按钮就直接返回 return; std::vectorCPoint path FindPath(m_ptPlayer, m_ptExit); if (path.size() 2) return; CPen pen(PS_SOLID, 3, RGB(255, 153, 0)); CPen* pOldPen dc.SelectObject(pen); int half m_nCellSize / 2; // 路径点转成格子中心像素坐标MoveTo 起点LineTo 依次连下去 dc.MoveTo(m_ptPlayer.x * m_nCellSize half, m_ptPlayer.y * m_nCellSize half); for (size_t i 0; i path.size(); i) dc.LineTo(path[i].x * m_nCellSize half, path[i].y * m_nCellSize half); dc.SelectObject(pOldPen); // 还原画笔GDI 对象数量有限 }格子坐标转像素时加m_nCellSize / 2是画在格子中心不加的话线会贴着格子左上角逐个连视觉上偏一格看着别扭。CPen用完必须SelectObject还原Windows 每个进程的 GDI 对象配额是有限的默认一万个一帧创建一支笔又不还原跑一会儿就画不出东西了。m_bShowHint建议做成「按一次显示若干秒后自动消失」用SetTimer另起一个 ID 控制别一直亮着那样游戏就没难度了。4.3 计时、步数与通关判定计时器在OnInitDialog里启动周期 1000 毫秒BOOL CMazeDlg::OnInitDialog() { CDialogEx::OnInitDialog(); srand((unsigned)time(nullptr)); // 每次启动的迷宫都不一样 m_nCellSize 24; ResetGame(); // 生成迷宫、重置玩家、步数、用时 SetTimer(1, 1000, nullptr); // ID 必须是 1 以上0 会被当成系统分配给窗口的 return TRUE; } void CMazeDlg::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent 1 !m_bWin) { m_nSeconds; UpdateStatus(); // 只更新文字控件不触发整个客户区重绘 } CDialogEx::OnTimer(nIDEvent); }SetTimer的 ID 传 0 会由系统分配一个并返回之后你就不知道该往OnTimer里比对什么了固定写 1 最省心。UpdateStatus只SetWindowText给静态文本控件别顺手Invalidate那样每秒整屏重绘一次白白浪费。通关判定放在OnWin里m_bWin先置位再弹窗否则玩家在弹窗出现前手快多按几下会连弹好几个框void CMazeDlg::OnWin() { if (m_bWin) return; m_bWin true; KillTimer(1); // 停表 CString strMsg; strMsg.Format(_T(走出来了\n步数%d 用时%d 秒), m_nSteps, m_nSeconds); MessageBox(strMsg, _T(通关), MB_ICONINFORMATION); }CString::Format里的_T()不能省工程是 Unicode 字符集直接把窄字符串字面量传给MessageBox会编译失败报「无法将参数 2 从 const char* 转换为 LPCTSTR」——这个报错第一次见很懵其实就是字符集没对上。5. 上手改之前先避坑编译、资源与 GDI 的几个老问题这套代码在原作者机器上能跑换台机器、换个 VS 版本往往就出状况。下面几条是我自己和新人都遇到过的按「现象 → 原因 → 解决」写清楚能省下不少搜论坛的时间。5.1 编译就挂找不到 afxwin.h或者一堆无法解析的外部符号现象打开工程直接编译报Cannot open include file: afxwin.h把包含路径补上之后又变成LNK2019 无法解析的外部符号报的全是 MFC 内部的类。原因两个独立问题叠在一起。前者是这台机器装 VS 时没勾「用于最新 v143 生成工具的 C MFC」这个组件头文件压根不存在后者是工程属性里 MFC 链接方式选成了「使用标准 Windows 库」编译器不链接 MFC 的 lib。解决打开 Visual Studio Installer勾上 MFC 组件装一遍。工程属性 → 高级 → MFC 的使用改成「在共享 DLL 中使用 MFC」或「在静态库中使用 MFC」重新生成。改完记得把 Debug 和 Release 两个配置都改一遍只改一个的话换配置编译又挂很容易误以为是代码问题。5.2 程序能跑但客户区一片空白现象exe 启动对话框弹出来了按钮也在就是画迷宫那块区域全白怎么按方向键都没变化。原因最常见的两个。一是对话框模板上放的那个静态图片控件或者占位控件 ID 跟代码里GetDlgItem用的对不上CWnd*拿到nullptr后面所有绘制静默失败二是OnPaint里算坐标用的是控件客户区但画笔和位图尺寸按主窗口算的画到了可视区外面。解决先在OnPaint开头下一行断点看rcClient的宽高是不是接近 0。是 0 就把绘制目标改成主对话框自身的GetClientRect别用占位控件的不是 0 就在BitBlt那行断点看位图有没有内容。ID 对不上的问题展开resource.h挨个核对比猜快得多。5.3 画面一直闪按方向键时尤其明显现象格子画出来了但每次移动屏幕都白一下连按方向键像在放幻灯片。原因三个原因会叠加。OnPaint直接往CPaintDC画OnEraseBkgnd用了默认实现先刷白底再画内容Invalidate(TRUE)每次移动都请求擦背景。解决三处一起改。OnEraseBkgnd直接return TRUEOnPaint改双缓冲全部画完再BitBltInvalidate传FALSE。改完还是闪的话检查一下有没有在OnKeyDown里调UpdateWindow()那等于绕过了系统的重绘合并强制立即刷屏。5.4 方向键完全没反应鼠标点一下窗口才偶尔能动现象OnKeyDown里下了断点按方向键断点根本不进按空格或者字母键倒是能进。原因对话框默认把方向键用于控件间的焦点切换WM_KEYDOWN在到达对话框之前就被吃掉了。焦点停在按钮上时更明显方向键被按钮拿去做了内部导航。解决在消息映射里加ON_WM_GETDLGCODEOnGetDlgCode返回DLGC_WANTARROWS | DLGC_WANTCHARS。如果还是不进就重写PreTranslateMessage在消息派发到控件之前截下来BOOL CMazeDlg::PreTranslateMessage(MSG* pMsg) { if (pMsg-message WM_KEYDOWN !m_bWin) { OnKeyDown((UINT)pMsg-wParam, 1, 0); return TRUE; // 吞掉消息不再往下派发 } return CDialogEx::PreTranslateMessage(pMsg); }用了PreTranslateMessage就不要再指望ON_WM_KEYDOWN被触发两条路选一条走同时开着会处理两次按一次键走两格。5.5 生成的迷宫有孤岛某些格子永远走不到现象迷宫看着是通的但从起点出发BFS 返回的路径是空的或者某些区域怎么绕都进不去。原因生成时边界判断写成了nx m_nCols没有留出一格余量打通墙的时候写到了vector外面operator[]不做检查Release 下就是越界写被踩坏的那片内存又恰好是网格数据于是出现孤岛。Debug 下通常会直接弹vector subscript out of range断言。解决边界统一写成nx 0 || ny 0 || nx m_nCols - 1 || ny m_nRows - 1最外圈永远留墙。改完加一句自检生成结束后从 (1,1) 做一次 BFS统计可达格子数跟路的总数比一下不相等就说明生成有问题。这行自检在 Debug 下常驻能提前发现大部分下标错误。6. 二次开发把 21×21 扩成 N×N再加一条能关掉的提示线这套代码最值钱的地方是它留了扩展的口子——行列数、格子大小、计时周期全是成员变量不是硬编码。想让它变成自己的东西第一件事就是把这些参数抽到界面上。改动思路很直接对话框上放两个CEdit控件绑int成员DDX 里DDX_Text(pDX, IDC_EDIT_ROWS, m_nRows)一个「重开」按钮里读参数、调ResetGame()。ResetGame内部先KillTimer再GenerateMaze再重置玩家和计数最后重新SetTimer不然重开一局计时器会叠加时间跳得飞快。下面这几个参数是实际改起来最常动的调之前心里有个数参数位置建议范围影响行列数GenerateMaze入参1161 的奇数超过 61 单屏放不下要加滚动或缩放格子边长m_nCellSize1240决定窗口大小太小玩家看不清自己在哪提示次数新增m_nHintLeft15有限次才有意思无限次等于自动通关计时周期SetTimer第二参数1000改成 100 就是 0.1 秒精度显示要改格式真正容易出问题的是窗口尺寸。格子边长乘行列数超过屏幕高度时对话框底部的状态栏会被挤出屏幕用户看不到步数和用时。做法是在ResetGame里算一下m_nRows * m_nCellSize超过某个阈值就等比缩小m_nCellSize同时MoveWindow把对话框拉大到刚好装下。这几十行代码比我预想的麻烦但不写的话参数一大就没法用。第二个值得加的功能是提示路径的开关和次数限制。m_bShowHint改成int m_nHintLeft点一次减一减到 0 按钮置灰void CMazeDlg::OnBnClickedHint() { if (m_nHintLeft 0 || m_bWin) return; --m_nHintLeft; m_bShowHint true; Invalidate(FALSE); // 提示只亮 3 秒用另一个 Timer ID别跟计时器混用 SetTimer(2, 3000, nullptr); GetDlgItem(IDC_BTN_HINT)-SetWindowText(_T(提示0)); GetDlgItem(IDC_BTN_HINT)-EnableWindow(FALSE); } void CMazeDlg::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent 1 !m_bWin) { m_nSeconds; UpdateStatus(); } else if (nIDEvent 2) { KillTimer(2); m_bShowHint false; Invalidate(FALSE); } CDialogEx::OnTimer(nIDEvent); }验证改对了没有我一般不看画面看数字随机跑十局每局都用 BFS 从起点算一次路径长度跟手动走的最短步数对一下对得上说明生成和判定都没问题。再跑一局 61×61掐表看生成耗时——DFS 是 O(行×列)正常应该在 10 毫秒以内超过就说明哪里写成了平方复杂度。还有一行小改动收益很高OnInitDialog里加SetWindowText(_T(迷宫))然后把 exe 的图标换掉。默认图标是 MFC 那个白色方块发给别人一看就知道是随手编译的。这套代码我第一次改的时候把m_nCellSize从 24 调到 40结果窗口超出屏幕还以为是自己哪行算错了折腾了半小时才发现是MoveWindow的参数单位是像素而GetClientRect拿到的是客户区两者差了标题栏和边框的高度。从那以后我每次动跟尺寸有关的参数都强制走一遍「生成 → 截图 → 量像素」的流程再也不靠肉眼估。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑