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::vector<std::vector<int>> 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::vector<std::vector<int>>& grid, int rows, int cols) { // 初始全部填墙 for (auto& row : grid) std::fill(row.begin(), row.end(), CELL_WALL); // 从(1,1)开始,步长为2地打通 std::stack<CPoint> 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::vector<int> 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); // ID=1,周期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::vector<int>(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那套理由讲出来,这是有依据的选型,不是随手选的。
有个习惯我从做完这个项目后一直保留:任何带状态的小游戏,先把“重置函数”写出来,再写其他逻辑。因为重置函数逼你把所有状态列全,列不全就编译不过或者跑不对,等于提前把脏状态的坑填了。希望这套结构能帮你把课设稳稳交上去。
本文还有配套的精品资源,点击获取