MFC迷宫游戏课设实战:从随机生成到状态管理的完整方案 简介这份资源是一个基于 MFC 开发的简易迷宫游戏完整工程面向正在学习 C 与 Windows 程序设计、希望借助小项目理解 GUI 开发的初学者和进阶者。它把迷宫地图绘制、玩家移动、路径搜索等逻辑封装进一个可编译运行的 MFC 程序帮助读者解决从语法到实际界面应用之间的过渡问题。压缩包共 45 个文件约 2.13MB包含 7 个 h 头文件与 6 个 cpp 源文件承载核心逻辑另有 obj、pdb、ilk 等编译中间文件png、bmp、ico 等图片与图标资源以及 rc 资源脚本和 dsp、dsw 工程文件结构完整、可直接用 VC6 打开。已有 378 人学习下载。读者可从中了解文档/视图架构、菜单与控件的事件绑定、CPen/CBrush 图形绘制以及用二维数组存储迷宫、以 DFS 或 BFS 搜索路径的实现思路并参考附带的思路说明与分步截图快速上手 MFC 游戏开发。1. 从一份“能跑起来”的课设说起MFC 迷宫游戏到底要交付什么课程设计布置下来题目是“用 MFC 做一个简单的迷宫游戏”很多人第一反应是打开对话框资源拖几个按钮画个网格能走通就算完事。真到验收那天导师问一句“迷宫怎么生成的、碰撞怎么判的、重开为什么状态没清干净”场面就有点尴尬。这个项目的难点从来不是“画格子”而是把随机生成、键盘响应、碰撞判定、计时计步、胜负判定这几件事串成一条稳定的状态链让它在任意操作顺序下都不崩。这套方案面向两类人一是要交 MFC 课设、需要一份结构完整可照着写的学生二是想拿一个轻量桌面小游戏练手 Win32 消息机制的开发者。下面按需求、设计、实现、避坑、验收的顺序把这份迷宫游戏方案拆开讲透。2. 需求与总体设计单文档结构下的五个核心模块2.1 功能边界先划清别一上来就堆控件“简单迷宫游戏”这个描述很模糊必须先把它翻译成可验收的功能条目否则做到一半会不断加需求。我一般会把范围压到下面这几条既够撑起一份课设的工作量又不至于失控功能项具体要求验收方式迷宫生成每次开局随机生成一张连通的可解迷宫连续开局 20 次每次都能从起点走到终点玩家移动方向键控制撞墙不穿透贴墙连续按键位置不越界计时计步实时显示用时与步数走一步加一步计时每秒刷新胜负判定到达终点弹出提示并停止计时到达终点后计时不再增长重开与难度一键重开可切换迷宫尺寸重开后步数、计时、地图全部复位这张表就是后面所有设计的锚点。凡是表里没有的比如音效、排行榜、存档一律不进第一版。课设翻车的典型原因不是做得少而是贪多最后每个功能都半成品。把边界定死后面写代码时心里有数答辩时也能一条条对着讲。2.2 为什么选单文档而不是纯对话框MFC 建工程时有三个常见选项单文档SDI、多文档MDI、基于对话框。做迷宫游戏我推荐单文档结构理由很实在。基于对话框的工程把主窗口当成一个对话框绘图要往对话框的客户区画消息处理容易和控件消息搅在一起键盘响应还得额外处理焦点问题而单文档天生带一个CView派生类OnDraw就是给你画图的OnKeyDown就是给你收键盘的窗口重绘、菜单、工具栏都是现成的。迷宫这种“一张画布 键盘输入”的形态和视图/文档模型几乎是天然契合。具体到类划分我一般拆成五块CMazeDoc负责数据迷宫矩阵、玩家坐标、步数、计时状态CMazeView负责绘制和键盘输入一个独立的CMazeGenerator类只管生成算法一个CGameTimer或直接用SetTimer管计时主框架类CMainFrame挂菜单和状态栏。数据与显示分离之后重开游戏只需要重置文档对象视图下一帧自然就画对了不用满世界找残留状态。2.3 迷宫数据怎么存二维数组加一圈“虚拟墙”迷宫的本质是一张网格图。最省事也最不容易出错的存法是二维整型数组每个格子用枚举值表示类型// MazeDoc.h 中的核心数据结构 enum CellType { CELL_WALL 0, // 墙 CELL_PATH 1, // 通路 CELL_START 2, // 起点 CELL_END 3 // 终点 }; class CMazeDoc : public CDocument { protected: int m_nRows; // 行数 int m_nCols; // 列数 int m_nCellSize; // 每格像素边长 std::vectorstd::vectorint m_grid; // 迷宫矩阵 CPoint m_ptPlayer; // 玩家当前格坐标(列,行) CPoint m_ptStart; // 起点 CPoint m_ptEnd; // 终点 int m_nSteps; // 步数 int m_nSeconds; // 已用秒数 bool m_bFinished; // 是否已通关 };这里有个血泪经验矩阵四周最好留一圈固定为墙的边界也就是实际可走区域是(rows-2) × (cols-2)。这样判断“能不能往左走”时只需要看目标格是不是CELL_PATH或CELL_END不用额外写x-1 0的边界判断。少一层条件就少一类越界崩溃。m_nCellSize单独抽出来是为了窗口缩放或换难度时只改一个参数绘制和坐标换算都跟着它走。2.4 生成算法选型深度优先“挖洞”比随机撒墙靠谱生成迷宫有几种常见思路随机撒墙再检查连通性、递归分割、深度优先回溯。前两种要么容易生成不可解的迷宫要么实现起来分支多。我一般用深度优先回溯也叫递归挖洞逻辑简单、保证连通、代码量小。思路是从起点格出发每次随机挑一个“隔一格”的邻居如果那格还没被访问过就把中间那格和邻居那格都打通然后递归进去四个方向都走不通就回溯。因为每次跨两格天然形成“墙—路—墙—路”的网格感。下面是一个非递归版本用栈代替递归避免大迷宫时栈太深// MazeGenerator.cpp void CMazeGenerator::Generate(std::vectorstd::vectorint grid, int rows, int cols) { // 初始全部填墙 for (auto row : grid) std::fill(row.begin(), row.end(), CELL_WALL); // 从(1,1)开始步长为2地打通 std::stackCPoint stk; CPoint start(1, 1); grid[start.y][start.x] CELL_PATH; stk.push(start); const int dx[4] { 0, 0, -2, 2 }; const int dy[4] { -2, 2, 0, 0 }; while (!stk.empty()) { CPoint cur stk.top(); // 收集所有未访问的隔格邻居 std::vectorint dirs; for (int i 0; i 4; i) { int nx cur.x dx[i]; int ny cur.y dy[i]; if (nx 0 nx cols - 1 ny 0 ny rows - 1 grid[ny][nx] CELL_WALL) dirs.push_back(i); } if (dirs.empty()) { stk.pop(); continue; } int d dirs[rand() % dirs.size()]; int nx cur.x dx[d]; int ny cur.y dy[d]; // 打通中间格和目标格 grid[cur.y dy[d] / 2][cur.x dx[d] / 2] CELL_PATH; grid[ny][nx] CELL_PATH; stk.push(CPoint(nx, ny)); } }参数说明rows、cols建议取奇数比如 21×21、31×31因为算法按步长 2 推进偶数尺寸会在边缘留出无法利用的墙带。dx/dy用 ±2 是这套算法的关键它保证打通的两格之间一定隔着一堵墙视觉上才是标准迷宫。生成完成后起点设在(1,1)终点设在(cols-2, rows-2)由于算法保证所有通路格连通终点必然可达。这一步做完可以顺手写个 BFS 验证连通性作为自测手段答辩时也是加分项。3. 视图绘制与键盘交互把消息映射写对游戏就活了一半3.1 OnDraw 里画什么、按什么顺序画视图的OnDraw是每帧重绘的入口。绘制顺序建议固定为先铺背景再画迷宫格子最后画玩家。顺序错了会出现玩家被墙盖住的情况。坐标换算统一走一个辅助函数避免到处写x * cellSize// MazeView.cpp void CMazeView::OnDraw(CDC* pDC) { CMazeDoc* pDoc GetDocument(); ASSERT_VALID(pDoc); if (!pDoc) return; int cs pDoc-m_nCellSize; // 1. 背景 CRect rcClient; GetClientRect(rcClient); pDC-FillSolidRect(rcClient, RGB(245, 245, 245)); // 2. 迷宫格子 for (int r 0; r pDoc-m_nRows; r) { for (int c 0; c pDoc-m_nCols; c) { CRect rc(c * cs, r * cs, (c 1) * cs, (r 1) * cs); int cell pDoc-m_grid[r][c]; COLORREF color (cell CELL_WALL) ? RGB(40, 40, 40) : (cell CELL_START) ? RGB(80, 180, 80) : (cell CELL_END) ? RGB(220, 80, 80) : RGB(255, 255, 255); pDC-FillSolidRect(rc, color); pDC-Draw3dRect(rc, RGB(200, 200, 200), RGB(200, 200, 200)); } } // 3. 玩家画成圆视觉上更醒目 CPoint pt pDoc-m_ptPlayer; CRect rcPlayer(pt.x * cs, pt.y * cs, (pt.x 1) * cs, (pt.y 1) * cs); pDC-Ellipse(rcPlayer); }Draw3dRect画一圈浅灰边框是为了让相邻格子有分隔感不然一片白墙一片白路玩家容易看花眼。玩家用Ellipse而不是填色方块是为了和格子类型在视觉上区分开。这里有个细节OnDraw里不要做任何耗时计算迷宫矩阵在文档里已经算好视图只负责读和画。把计算塞进绘制函数窗口一拖动就卡这是新手常踩的坑。3.2 键盘消息映射OnKeyDown 与焦点问题MFC 里收键盘消息最直接的是给视图类加WM_KEYDOWN的映射。用类向导生成后MazeView.cpp里会出现ON_WM_KEYDOWN()然后实现OnKeyDown// MazeView.cpp 消息映射 BEGIN_MESSAGE_MAP(CMazeView, CView) ON_WM_KEYDOWN() ON_WM_TIMER() END_MESSAGE_MAP() void CMazeView::OnKeyDown(UINT nChar, UINT nRepCnt, UINT nFlags) { CMazeDoc* pDoc GetDocument(); if (pDoc-m_bFinished) return; // 已通关忽略输入 CPoint next pDoc-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: CView::OnKeyDown(nChar, nRepCnt, nFlags); return; } // 越界与撞墙判定 if (next.x 0 || next.x pDoc-m_nCols || next.y 0 || next.y pDoc-m_nRows) return; if (pDoc-m_grid[next.y][next.x] CELL_WALL) return; pDoc-m_ptPlayer next; pDoc-m_nSteps; if (next pDoc-m_ptEnd) { pDoc-m_bFinished true; KillTimer(1); AfxMessageBox(_T(通关)); } Invalidate(); // 触发重绘 }几个关键点。第一Invalidate()只是标记区域无效真正重绘在消息循环空闲时发生所以连续快速按键不会卡死这是 MFC 的正常节奏。第二撞墙时直接return不更新坐标也不加步数这样“步数”才真实反映有效移动。第三通关后立刻KillTimer并置m_bFinished否则计时器还在跑玩家还能继续按键状态就乱了。第四default分支要调基类实现不然 Tab、方向键之外的键会吞掉影响菜单快捷键。焦点问题值得单独说一句。如果工程是基于对话框的视图不存在键盘消息会先到对话框需要重写PreTranslateMessage或者给对话框设焦点。这也是前面推荐单文档的原因之一——视图默认就能拿到键盘焦点省掉一层麻烦。3.3 计时器与状态刷新SetTimer 的坑要提前躲计时用SetTimer最省事。在视图初始化时开一个 1 秒周期的定时器OnTimer里累加秒数并刷新状态栏或标题// MazeView.cpp void CMazeView::OnInitialUpdate() { CView::OnInitialUpdate(); SetTimer(1, 1000, nullptr); // ID1周期1000ms } void CMazeView::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent 1) { CMazeDoc* pDoc GetDocument(); if (!pDoc-m_bFinished) { pDoc-m_nSeconds; CString s; s.Format(_T(用时 %d 秒 步数 %d), pDoc-m_nSeconds, pDoc-m_nSteps); GetParentFrame()-SetWindowText(s); // 显示在标题栏 } } CView::OnTimer(nIDEvent); }SetTimer的坑在于窗口销毁时定时器不会自动清理如果重开游戏时又SetTimer一次就会叠加多个同 ID 定时器表现为计时突然变快。正确做法是重开时先KillTimer(1)再SetTimer或者在OnDestroy里统一清理。另一个坑是OnTimer里不要做重活它跑在 UI 线程阻塞了界面就假死。计时这种轻量累加没问题但别在里面重新生成迷宫。4. 游戏逻辑闭环重开、难度切换与状态复位4.1 重开游戏为什么总留“脏状态”重开是课设里最容易翻车的地方。现象是点重开后地图变了但步数还是上一局的或者计时器还在跑甚至玩家还停在旧位置。根因是状态分散在多个成员变量里重开时只重置了其中一部分。稳妥的做法是把“一局游戏的全部状态”收拢到一个重置函数里任何入口都只调它// MazeDoc.cpp void CMazeDoc::ResetGame(int rows, int cols) { m_nRows rows; m_nCols cols; m_grid.assign(rows, std::vectorint(cols, CELL_WALL)); CMazeGenerator gen; gen.Generate(m_grid, rows, cols); m_ptStart CPoint(1, 1); m_ptEnd CPoint(cols - 2, rows - 2); m_grid[m_ptStart.y][m_ptStart.x] CELL_START; m_grid[m_ptEnd.y][m_ptEnd.x] CELL_END; m_ptPlayer m_ptStart; m_nSteps 0; m_nSeconds 0; m_bFinished false; }这个函数把矩阵、起点终点、玩家位置、步数、秒数、完成标志一次性写全。视图那边重开时先KillTimer调ResetGame再SetTimer最后Invalidate。顺序不能反先重绘再重置会出现一帧旧图。难度切换本质就是换rows/cols再调同一个函数所以菜单里“简单/中等/困难”三个项最终都落到ResetGame(21,21)、ResetGame(31,31)、ResetGame(41,41)这样的调用上代码只有一份。4.2 菜单与命令响应怎么挂单文档工程自带菜单资源。在资源编辑器里加一个“游戏”菜单下面放“重新开始”“简单”“中等”“困难”几个项给每项设好 ID比如ID_GAME_RESTART、ID_LEVEL_EASY。然后在视图类或文档类里加命令映射// MazeView.cpp BEGIN_MESSAGE_MAP(CMazeView, CView) ON_COMMAND(ID_GAME_RESTART, CMazeView::OnGameRestart) ON_COMMAND(ID_LEVEL_EASY, CMazeView::OnLevelEasy) ON_COMMAND(ID_LEVEL_MEDIUM, CMazeView::OnLevelMedium) ON_COMMAND(ID_LEVEL_HARD, CMazeView::OnLevelHard) END_MESSAGE_MAP() void CMazeView::OnGameRestart() { CMazeDoc* pDoc GetDocument(); KillTimer(1); pDoc-ResetGame(pDoc-m_nRows, pDoc-m_nCols); // 保持当前难度 SetTimer(1, 1000, nullptr); Invalidate(); }命令映射放在视图里还是文档里取决于操作是否涉及界面。重开要动定时器和重绘放视图更顺如果只是改数据放文档也行。关键是别两边都写一份否则改一处漏一处又是脏状态的来源。4.3 窗口缩放与格子尺寸自适应固定格子尺寸在窗口拉大时会留大片空白拉小时又画不全。简单做法是在OnSize里根据客户区大小反算m_nCellSize取宽高两个方向能容纳的较小值void CMazeView::OnSize(UINT nType, int cx, int cy) { CView::OnSize(nType, cx, cy); CMazeDoc* pDoc GetDocument(); if (!pDoc || pDoc-m_nRows 0) return; int csW cx / pDoc-m_nCols; int csH cy / pDoc-m_nRows; pDoc-m_nCellSize max(4, min(csW, csH)); // 下限4像素防止除零和过小 Invalidate(); }max(4, ...)这个下限是后悔药窗口被拖到极小时csW可能算出 0后面坐标换算全变 0绘制就乱了。加个下限最差情况是画面被裁掉一部分但不会崩。这个细节不写进代码答辩演示时把窗口一缩就可能当场翻车。5. 避坑与排查这类小游戏最容易翻车的五个点5.1 迷宫生成了但走不到终点现象开局后玩家被墙围死或者终点在一片孤岛里。原因通常是生成算法写错比如打通时只改了目标格没改中间格或者边界处理把通路截断。解决生成后跑一遍 BFS从起点出发标记所有可达格检查终点是否被标记没标记就重新生成。这个自检函数平时不显示调试时调用能省掉大量肉眼找路的时间。5.2 连续按键导致玩家“穿墙”现象快速连按方向键玩家偶尔跳过一堵墙。原因多半是OnKeyDown里用了nRepCnt做逻辑或者把移动写在了OnKeyUp里导致节奏错乱。解决移动逻辑只认nChar每次按键只移动一格撞墙直接返回不要根据按键重复次数做多格移动。键盘自动重复本身就会连续触发WM_KEYDOWN按格处理天然安全。5.3 计时器越跑越快现象重开几次后秒数增长明显快于真实时间。原因就是前面说的定时器叠加多次SetTimer同 ID 没清理。解决所有开定时器的地方配对KillTimer重开流程固定为“先杀后开”在OnDestroy里再兜底杀一次。排查时可以在OnTimer里打个计数器看一秒内触发几次超过一次就是叠加了。5.4 窗口最小化再恢复画面花屏现象最小化后还原迷宫只画了一半或者出现残影。原因是OnDraw依赖了某些只在初始化时算一次的缓存或者没处理WM_ERASEBKGND。解决OnDraw里所有绘制都从文档数据现算不缓存坐标背景用FillSolidRect整块铺满。如果还有残影检查是否在OnPaint之外用了GetDC直接画那种画法不受重绘机制管理很容易留脏。5.5 Release 版能跑Debug 版断言失败现象调试版一运行就弹ASSERT_VALID失败或访问越界。原因常见于GetDocument()返回空时没判空或者矩阵下标用了未初始化的成员。解决所有GetDocument()后加判空矩阵访问前确认m_nRows/m_nCols已赋值ResetGame在文档构造函数里也调一次保证任何入口进来数据都是合法的。Debug 版的断言其实是黑匣子它比 Release 更早暴露问题别嫌它烦。6. 验收与答辩怎么把这份迷宫游戏讲成一份完整方案验收前我一般会按一张自检表走一遍确保演示时不出洋相检查项操作预期连通性连续重开 20 次每次走一遍每次都能到终点撞墙贴着墙连续按方向键位置不变步数不增计时开局后静置 10 秒显示约 10 秒误差不超过 1 秒重开走几步后点重开步数归零、计时归零、玩家回起点难度依次切换三档格子数变化画面自适应缩放拖动窗口边缘迷宫等比缩放不花屏答辩时不要从头念代码按“需求—设计—实现—验证”四段讲。先亮功能表说明边界为什么这么划再讲类划分重点说清文档管数据、视图管显示这条分离线然后挑生成算法和键盘判定两个点展开这两处最能体现你懂原理而不是拼控件最后现场演示自检表里的几项尤其是重开和缩放这两个动作最能暴露状态管理是否干净。导师追问“为什么不用对话框工程”就把焦点和OnDraw那套理由讲出来这是有依据的选型不是随手选的。有个习惯我从做完这个项目后一直保留任何带状态的小游戏先把“重置函数”写出来再写其他逻辑。因为重置函数逼你把所有状态列全列不全就编译不过或者跑不对等于提前把脏状态的坑填了。希望这套结构能帮你把课设稳稳交上去。本文还有配套的精品资源点击获取