简介:这份资源是一个基于MFC框架开发的简易迷宫游戏完整工程,面向正在学习C++与Windows程序设计、希望了解MFC实际应用的初学者和进阶者。项目以文档/视图架构组织代码,通过CWnd窗口管理、菜单与按钮控件、事件驱动消息映射以及CPen、CBrush等GDI绘图类,实现迷宫地图的展示与玩家交互,并涉及二维数组存储迷宫结构、DFS或BFS路径搜索、玩家位置与已探索路径的状态记录等核心逻辑,是理解面向对象编程与游戏逻辑结合的良好范例。压缩包为rar格式,共45个文件,约2.13MB,包含7个h头文件、6个cpp源文件,以及obj、pdb、ilk等编译中间文件,png、bmp、ico等图片与图标资源,还有rc资源脚本、dsp与dsw工程文件及ReadMe说明,目录结构完整,可直接用VC6打开编译运行。目前已有378人学习下载,适合对照源码梳理MFC工程组织方式、调试游戏逻辑并动手修改迷宫地图。
1. 从一份“能跑起来”的课设说起:MFC 迷宫游戏到底要交付什么
课程设计最怕的不是功能少,而是交上去跑不起来。很多同学一上来就想做炫酷的 3D 迷宫,结果卡在渲染和碰撞检测上,最后连一个能演示的窗口都拿不出来。MFC 迷宫游戏这个题目看着朴素,实际上是一份很典型的 Windows 桌面小游戏方案:单文档或对话框框架、二维数组存地图、键盘或按钮控制角色移动、判断是否撞墙、走到终点给出提示。它适合两类人——正在做 C++ 课程设计、需要一份结构完整能答辩的作业的学生,以及想快速摸清 MFC 绘图与消息机制、拿个小项目练手的入门开发者。这套方案的核心不是算法多深,而是把“地图数据、界面绘制、输入响应、胜负判定”四件事拆干净,每一块都能单独调试、单独验收。
2. 需求与总体设计:把迷宫拆成地图、角色、规则三块
2.1 功能边界先划清楚,别让需求膨胀
动手写代码之前,我一般会先把这一版要做什么、不做什么写在一张纸上。MFC 迷宫游戏的合格交付范围通常包括:一个固定或可配置大小的迷宫地图、一个用方向键或屏幕按钮控制的角色、撞墙不能穿过去、到达出口弹出胜利提示、提供重新开始。可选加分项是计时、步数统计、多关卡切换。明确不做的部分同样重要——比如随机生成算法、寻路提示、存档读档,这些放进“后续扩展”里说,答辩时反而显得你想过全局。
需求边界定下来,技术选型就顺了。界面用 MFC 的对话框程序还是单文档程序?我的血泪经验是:课程设计优先选基于对话框的工程。对话框程序没有文档/视图那一套框架负担,直接在OnPaint里画、在OnKeyDown里收键盘,代码路径短,出问题好定位。单文档程序适合以后要加菜单、工具栏、多视图的场景,但对一个迷宫小游戏来说属于杀鸡用牛刀,还容易在OnDraw和Invalidate的调用时机上翻车。
2.2 数据结构:二维数组是迷宫的地基
迷宫的本质是一张网格图,最直接的表示就是一个二维整型数组。约定0表示通路、1表示墙、2表示终点,角色位置单独用行列坐标记录。地图尺寸建议做成常量,方便后面改关卡。
// MazeGameDlg.h 中的核心数据成员 const int ROW_COUNT = 15; // 迷宫行数 const int COL_COUNT = 15; // 迷宫列数 const int CELL_SIZE = 32; // 每个格子的像素边长 int m_map[ROW_COUNT][COL_COUNT]; // 0通路 1墙 2终点 int m_playerRow; // 角色当前行 int m_playerCol; // 角色当前列 int m_stepCount; // 已走步数 bool m_bWin; // 是否已到达终点这里有几个参数值得说清楚。CELL_SIZE决定画面缩放,改大格子画面变大但窗口可能超出屏幕,改小则角色和墙的区分度下降,32 像素在 1080P 屏上是比较稳的取值。ROW_COUNT和COL_COUNT不要求相等,但建议都取奇数,因为手工画迷宫时墙和通路交替排列,奇数尺寸更容易画出规整的通道。地图数据在对话框初始化函数OnInitDialog里一次性写入,后面所有逻辑都只读这张表,改关卡就是换一组初始化数据,不动任何逻辑代码。
2.3 模块划分与消息流向
整个程序按职责分成四块,彼此通过成员变量和成员函数交互,不搞全局变量满天飞:
| 模块 | 主要职责 | 关键函数 |
|---|---|---|
| 地图数据 | 存储与初始化关卡 | InitMap() |
| 绘制模块 | 把地图和角色画到窗口 | OnPaint() |
| 输入模块 | 接收方向键并尝试移动 | OnKeyDown() |
| 规则模块 | 判断撞墙、到达终点、重置 | TryMove()、ResetGame() |
消息流向很清晰:用户按键触发OnKeyDown,它调用TryMove修改角色坐标,TryMove内部查地图判断合法性,坐标变化后调用Invalidate请求重绘,系统再回调OnPaint把最新状态画出来。这条链路是 MFC 绘图类程序的标准套路,理解它之后,后面加计时器、加动画都是往这条链上挂东西。
3. 核心实现:绘制、移动与胜负判定的落地代码
3.1 在 OnPaint 里把地图画出来
MFC 的绘图必须围绕设备上下文(CDC)来做,所有画线、画矩形、填色都通过它完成。OnPaint是窗口需要重绘时被调用的地方,迷宫的所有可视元素都在这里输出。
void CMazeGameDlg::OnPaint() { CPaintDC dc(this); // 对话框程序的绘制设备上下文 // 双缓冲:先画到内存位图,再一次性贴到屏幕,避免闪烁 CRect rcClient; GetClientRect(&rcClient); CDC memDC; memDC.CreateCompatibleDC(&dc); CBitmap bmp; bmp.CreateCompatibleBitmap(&dc, rcClient.Width(), rcClient.Height()); CBitmap* pOld = memDC.SelectObject(&bmp); memDC.FillSolidRect(&rcClient, RGB(250, 250, 250)); for (int r = 0; r < ROW_COUNT; ++r) { for (int c = 0; c < COL_COUNT; ++c) { CRect cell(c * CELL_SIZE, r * CELL_SIZE, (c + 1) * CELL_SIZE, (r + 1) * CELL_SIZE); if (m_map[r][c] == 1) memDC.FillSolidRect(&cell, RGB(60, 60, 60)); // 墙 else if (m_map[r][c] == 2) memDC.FillSolidRect(&cell, RGB(80, 180, 80)); // 终点 else memDC.FillSolidRect(&cell, RGB(255, 255, 255));// 通路 } } // 画角色:用圆形和墙区分开 CBrush brush(RGB(200, 60, 60)); CBrush* pOldBrush = memDC.SelectObject(&brush); int cx = m_playerCol * CELL_SIZE + CELL_SIZE / 2; int cy = m_playerRow * CELL_SIZE + CELL_SIZE / 2; memDC.Ellipse(cx - CELL_SIZE / 3, cy - CELL_SIZE / 3, cx + CELL_SIZE / 3, cy + CELL_SIZE / 3); memDC.SelectObject(pOldBrush); dc.BitBlt(0, 0, rcClient.Width(), rcClient.Height(), &memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOld); }这段代码里最容易被忽略的是双缓冲。直接往CPaintDC上逐格填充,窗口一刷新就会看到明显闪烁,答辩演示时观感很差。做法是先建一个兼容内存 DC 和位图,所有绘制在内存里完成,最后用BitBlt一次性拷贝到屏幕。CreateCompatibleBitmap的尺寸必须取客户区大小,取小了会画不全,取大了浪费内存。另外注意SelectObject之后要把旧对象选回去,否则 GDI 对象泄漏,程序跑久了会出问题——这是 MFC 绘图最常见的坑之一。
3.2 键盘输入与移动合法性判断
方向键的响应要落在OnKeyDown上。对话框程序默认不接收方向键,需要在PreTranslateMessage里拦截,或者给对话框加WS_EX_CONTROLPARENT样式后重写OnKeyDown。我一般用前者,逻辑更直观。
BOOL CMazeGameDlg::PreTranslateMessage(MSG* pMsg) { if (pMsg->message == WM_KEYDOWN) { switch (pMsg->wParam) { case VK_UP: TryMove(-1, 0); return TRUE; case VK_DOWN: TryMove(1, 0); return TRUE; case VK_LEFT: TryMove(0, -1); return TRUE; case VK_RIGHT: TryMove(0, 1); return TRUE; } } return CDialogEx::PreTranslateMessage(pMsg); } void CMazeGameDlg::TryMove(int dRow, int dCol) { if (m_bWin) return; // 已胜利就不再响应移动 int nr = m_playerRow + dRow; int nc = m_playerCol + dCol; // 边界检查:越界一律视为撞墙 if (nr < 0 || nr >= ROW_COUNT || nc < 0 || nc >= COL_COUNT) return; if (m_map[nr][nc] == 1) return; // 撞墙,坐标不变 m_playerRow = nr; m_playerCol = nc; ++m_stepCount; if (m_map[nr][nc] == 2) // 到达终点 { m_bWin = true; CString msg; msg.Format(_T("到达终点!共走 %d 步。"), m_stepCount); MessageBox(msg, _T("胜利"), MB_OK | MB_ICONINFORMATION); } Invalidate(); // 请求重绘,触发 OnPaint }TryMove是整个游戏规则的核心,它把“能不能走”和“走了之后发生什么”收在一个函数里,调用方只管传方向。边界检查必须放在查地图之前,否则数组下标越界会直接崩溃,这是新手最容易翻车的地方。Invalidate只是标记窗口需要重绘,真正的绘制发生在消息循环空闲时,所以不要在TryMove里直接调OnPaint,那样会打乱绘制流程。步数统计放在移动成功之后累加,撞墙不计步,这个细节答辩时被问到能体现你想清楚了规则。
3.3 重置与关卡切换
重新开始就是把角色放回起点、步数清零、胜利标志复位,地图数据本身不用重写,除非要做多关卡。多关卡的实现方式是把每张地图的初始化数据抽成独立函数,ResetGame里根据当前关卡号调用对应函数。
void CMazeGameDlg::ResetGame() { m_playerRow = 1; // 起点固定在(1,1) m_playerCol = 1; m_stepCount = 0; m_bWin = false; Invalidate(); }起点坐标要和地图初始化数据对应上,如果地图里(1,1)是墙,角色一开局就卡在墙里,移动逻辑会全部失效。我的习惯是在InitMap末尾加一句断言或调试输出,确认起点和终点都是通路,这个检查花不了几分钟,能省掉大量排查时间。
4. 避坑与排查:MFC 小游戏最容易翻车的五个点
4.1 方向键完全没反应
现象是按上下左右角色纹丝不动,鼠标点按钮却正常。原因通常是对话框默认把方向键当成焦点切换键处理掉了,消息根本没进你的OnKeyDown。解决办法是重写PreTranslateMessage拦截WM_KEYDOWN,处理完返回TRUE表示消息已消费,不再往下传。如果仍然无效,检查对话框是否设置了WS_EX_CONTROLPARENT样式,以及焦点是否落在某个子控件上。
4.2 画面闪烁严重
现象是每次移动角色,整个窗口先变白再重画,肉眼可见地抖。原因是直接在CPaintDC上逐格绘制,系统在两次绘制之间把背景擦掉了。解决办法就是 3.1 里的双缓冲:内存位图绘制加BitBlt一次性输出。另外确认没有在OnEraseBkgnd里做多余的重绘,返回TRUE跳过默认背景擦除能进一步减少闪烁。
4.3 角色能穿墙或数组越界崩溃
现象有两种:角色走到墙格子上,或者走到地图边缘再按一下程序直接退出。前者是TryMove里漏了m_map[nr][nc] == 1的判断,后者是漏了边界检查。这两个判断的顺序有讲究,先查边界再查地图,因为越界时访问m_map本身就是未定义行为。修完之后建议把地图四周全部填成墙,相当于给迷宫加一圈围墙,双保险。
4.4 改了地图尺寸但画面错位
现象是调整了ROW_COUNT、COL_COUNT之后,格子画到窗口外面或者挤成一团。原因是窗口客户区大小没有跟着格子总数走。解决办法是在OnInitDialog里根据行列数和CELL_SIZE计算所需尺寸,调用SetWindowPos或MoveWindow调整对话框大小,同时把CELL_SIZE做成可配置项。改尺寸时记得同步检查起点终点坐标是否还在合法范围内。
4.5 胜利后还能继续移动或重复弹窗
现象是到达终点后弹了一次提示,继续按键角色还在走,甚至再走到终点又弹一次。原因是m_bWin标志没有在TryMove开头拦截,或者重置时忘了复位。修法很简单:TryMove第一行判断if (m_bWin) return;,ResetGame里把m_bWin置回false。这类状态标志的复位遗漏在带胜负判定的程序里非常普遍,建议把所有状态变量列个清单,重置函数里逐项过一遍。
5. 验收与答辩:怎么证明这份迷宫游戏是“完整”的
验收这件事,我一般按“能演示、能解释、能改动”三条线走。能演示是指从启动到胜利的完整流程一次跑通,中间不崩、不卡、不闪烁;能解释是指随便指一段代码你能说清它为什么这么写;能改动是指现场让你把地图换一张、把格子调大,你能在几分钟内改出来并跑通。这三条过了,课设基本稳了。
具体到操作层面,答辩前我会准备一张自检表,逐项打勾:
| 检查项 | 合格标准 | 常见扣分点 |
|---|---|---|
| 启动流程 | 双击 exe 直接进入游戏界面 | 依赖调试环境、缺 DLL |
| 移动响应 | 四个方向键均有效,撞墙不动 | 方向键无响应、能穿墙 |
| 胜利判定 | 到达终点弹提示且停止移动 | 重复弹窗、胜利后仍可走 |
| 重置功能 | 一键回到起点、步数清零 | 地图状态残留 |
| 界面观感 | 无闪烁、墙与通路区分明显 | 画面抖动、颜色相近 |
| 代码结构 | 地图、绘制、输入、规则分离 | 全部堆在一个函数里 |
答辩时老师最爱问的三个问题,提前想好答案:地图数据为什么用二维数组而不是链表(答:网格结构天然适合下标访问,查邻居是 O(1),实现简单);双缓冲解决了什么问题(答:消除逐格绘制导致的闪烁,原理是内存绘制后一次性拷贝);如果要加计时功能怎么改(答:加SetTimer定时器,OnTimer里累加时间并Invalidate刷新显示,和现有重绘链路兼容)。
还有一个容易被忽略的细节:演示时把窗口调到合适大小再开始,别在全屏状态下按方向键,有些环境里全屏窗口的键盘焦点会跑到别的控件上,现场翻车很难看。我吃过一次亏,后来每次答辩前都强制走一遍“启动、走完全程、重置、再走两步”的完整流程,确认没有状态残留才上台。希望这份方案能帮到你,把这份迷宫游戏稳稳交出去。
本文还有配套的精品资源,点击获取