简介:这是一份面向MFC初学者与Windows程序设计入门者的实战型小项目源码,围绕经典迷宫游戏展开,帮助读者理解如何用C++与MFC搭建可交互的图形界面程序。压缩包共45个文件,约2.13MB,以h头文件与cpp源文件为核心,配合rc资源脚本、ico与bmp图像素材、dsp与dsw工程文件及exe可执行程序,另附ReadMe说明与多张分步截图,完整呈现从工程配置到界面绘制的组织方式。已有378人学习下载。代码中可见文档/视图架构、窗口与菜单控件管理、鼠标键盘事件响应,以及用二维数组存储迷宫、以DFS或BFS搜索路径的实现思路,并配有玩家状态类与边界错误处理。读者可据此拆解MFC消息映射与GDI绘图流程,理解游戏数据与界面分离的设计,并参考截图自行修改迷宫地图,适合作为课程设计或自学练手素材。
1. 一个 MFC 迷宫游戏,为什么值得翻出来拆
手头这份资源是一个用 MFC 写的简单迷宫游戏,纯 C++、原生 Win32 窗口体系,没有第三方引擎,也没有资源包依赖。它解决的不是“做个能上架的迷宫游戏”,而是“我想找一个能跑、能改、能看懂的小型 MFC 图形交互样例”。适合两类人:一类是刚接触 MFC、想找一个比对话框计算器更有意思的练手项目;另一类是老手,想拿一个结构干净的小工程验证 GDI 绘图、定时器、键盘消息这几条链路。迷宫生成、角色移动、胜负判定都在一个工程里闭环,改起来不牵一发动全身,这点比很多“大而全”的示例更实用。
2. 工程结构与消息链路:先搞清楚谁在驱动谁
2.1 迷宫游戏的最小 MFC 骨架
MFC 程序看着文件多,真正决定行为的就那几个。一个典型的单文档或对话框式迷宫游戏,核心文件大致是这些:
| 文件 | 作用 | 改动频率 |
|---|---|---|
| 主程序类(App) | 初始化、注册窗口类、启动消息循环 | 基本不动 |
| 主窗口类(Frame/Dialog) | 承载绘图、接收键盘消息、持有游戏状态 | 高频改动 |
| 迷宫数据模块 | 二维数组或一维数组存墙/路/终点 | 高频改动 |
| 资源文件(.rc) | 菜单、图标、对话框模板 | 低频改动 |
迷宫本身通常就是一个二维整型数组,0 表示通路、1 表示墙、2 表示终点,角色位置单独用行列坐标记录。这个数据结构决定了后面所有逻辑:生成算法往里写,绘图按它画,移动判定查它。我一般会先把这个数组的尺寸定义成常量,比如MAZE_W、MAZE_H,避免到处写魔法数字。
// 迷宫数据与角色状态,放在主窗口类里最省事 const int MAZE_W = 21; // 奇数,方便用深度优先生成 const int MAZE_H = 21; int m_maze[MAZE_H][MAZE_W]; // 0 通路 1 墙 2 终点 int m_playerRow = 1; // 角色当前行 int m_playerCol = 1; // 角色当前列这里有个容易忽略的点:迷宫宽高取奇数。用深度优先(递归回溯)生成迷宫时,墙和路是交替的,偶数尺寸会在边界上出现半格,画出来歪歪扭扭。参数说明上,MAZE_W和MAZE_H不是随便填的,它们同时约束了生成算法的步进和绘图时的格子尺寸,改大之后要同步检查窗口客户区够不够画。
2.2 消息映射:键盘、定时器、重绘三条线
MFC 的消息映射是这套代码的“神经”。迷宫游戏真正用到的消息不多,但每一条都得接对:
WM_KEYDOWN:处理方向键,移动角色。WM_TIMER:如果做了自动寻路演示或动画,靠它推进。WM_PAINT:所有绘制入口,别在别处直接画。WM_ERASEBKGND:处理不好会闪屏,后面避坑章细说。
消息映射的写法是固定的,新手最容易漏的是在头文件里声明处理函数、在 cpp 里写BEGIN_MESSAGE_MAP宏。少一处,编译能过但按键没反应,这种“静默失败”最耗时间。
// 头文件里声明 afx_msg void OnKeyDown(UINT nChar, UINT nRepCnt, UINT nFlags); afx_msg void OnPaint(); afx_msg BOOL OnEraseBkgnd(CDC* pDC); // cpp 里绑定 BEGIN_MESSAGE_MAP(CMazeView, CView) ON_WM_KEYDOWN() ON_WM_PAINT() ON_WM_ERASEBKGND() END_MESSAGE_MAP()逻辑说明:ON_WM_KEYDOWN()这类宏把 Windows 消息和成员函数对上号,参数由框架填。注意OnKeyDown的nChar是虚拟键码,方向键对应VK_UP、VK_DOWN、VK_LEFT、VK_RIGHT,不是字符'W'。如果想让 WASD 也能走,就在同一个函数里加分支,别另开消息处理。
2.3 绘图坐标换算:格子到像素的那一步
迷宫画在客户区,核心是一次坐标换算:第r行第c列的格子,左上角像素是(c * CELL, r * CELL),CELL是每格边长。所有绘制——墙、路、角色、终点——都走这一个换算,别各画各的。
const int CELL = 24; // 每格 24 像素 void CMazeView::DrawMaze(CDC* pDC) { for (int r = 0; r < MAZE_H; ++r) { for (int c = 0; c < MAZE_W; ++c) { CRect rc(c * CELL, r * CELL, (c + 1) * CELL, (r + 1) * CELL); if (m_maze[r][c] == 1) pDC->FillSolidRect(rc, RGB(40, 40, 40)); // 墙 else if (m_maze[r][c] == 2) pDC->FillSolidRect(rc, RGB(220, 180, 40)); // 终点 else pDC->FillSolidRect(rc, RGB(245, 245, 245));// 通路 } } // 角色最后画,盖在格子上 CRect pr(m_playerCol * CELL, m_playerRow * CELL, (m_playerCol + 1) * CELL, (m_playerRow + 1) * CELL); pDC->FillSolidRect(pr, RGB(60, 120, 220)); }参数说明:CELL决定整体视觉大小,改它要同步确认窗口客户区至少是MAZE_W * CELL宽。FillSolidRect比Rectangle少画边框,批量绘制时更快,也不会因为画笔状态残留出现脏边。角色放在循环外最后画,是为了保证它永远在最上层,避免被后画的格子覆盖。
3. 迷宫生成与移动判定:把规则写死之前先想清楚
3.1 递归回溯生成:为什么它适合小迷宫
迷宫生成算法有好几种,这份资源场景下最合适的是递归回溯(深度优先)。原因很直接:代码短、结果保证连通、每条通路都能走到终点,不会生成“看着像迷宫但走不通”的死局。对一个小型 MFC 示例来说,可读性比生成速度重要得多。
算法思路:从起点开始,随机选一个未访问的相邻格(隔一格跳),打通中间的墙,递归下去,走不通就回溯。实现时把整个迷宫先填满墙,再从(1,1)开始挖。
void CMazeView::GenerateMaze(int r, int c) { m_maze[r][c] = 0; // 当前格打通 int dirs[4][2] = {{-2,0},{2,0},{0,-2},{0,2}}; // 简单洗牌,保证方向随机 for (int i = 3; i > 0; --i) { int j = rand() % (i + 1); std::swap(dirs[i][0], dirs[j][0]); std::swap(dirs[i][1], dirs[j][1]); } for (int i = 0; i < 4; ++i) { int nr = r + dirs[i][0]; int nc = c + dirs[i][1]; if (nr > 0 && nr < MAZE_H - 1 && nc > 0 && nc < MAZE_W - 1 && m_maze[nr][nc] == 1) { m_maze[r + dirs[i][0] / 2][c + dirs[i][1] / 2] = 0; // 打通中间墙 GenerateMaze(nr, nc); } } }逻辑说明:步进是 2 而不是 1,因为墙和路交替,跳一格才能保证打通的是“墙”。边界判断留出最外圈当围墙,所以条件是> 0和< 尺寸 - 1。rand()前记得srand((unsigned)time(nullptr)),否则每次启动迷宫一模一样,调试时反而容易误以为生成逻辑没跑。
参数上,递归深度最坏是格子数的一半,21×21 的迷宫完全撑得住;如果改成 101×101,递归层数上去,栈空间要留意,这种规模建议换迭代式或显式栈。
3.2 移动判定:一次只查一个格子
角色移动的判定比想象中简单:算出目标格,看它是不是墙,不是就更新坐标,是就不动。关键是别把“越界”和“撞墙”混在一起处理,否则边界上会出现角色穿墙或者卡死。
void CMazeView::MovePlayer(int dRow, int dCol) { int nr = m_playerRow + dRow; int nc = m_playerCol + dCol; if (nr < 0 || nr >= MAZE_H || nc < 0 || nc >= MAZE_W) return; // 越界直接忽略 if (m_maze[nr][nc] == 1) return; // 撞墙不动 m_playerRow = nr; m_playerCol = nc; if (m_maze[nr][nc] == 2) OnReachGoal(); // 到达终点 Invalidate(); // 触发重绘 }参数说明:dRow、dCol由按键决定,上是(-1,0),下是(1,0),左是(0,-1),右是(0,1)。Invalidate()不带参数会重绘整个客户区,小迷宫无所谓;如果迷宫很大、想省一点,可以只InvalidateRect角色前后两个格子,但那样要自己管脏矩形,新手不建议一上来就这么做。
这里有个设计取舍值得说:移动判定放在按键处理里,还是放在一个独立的TryMove里?我倾向后者,因为后面如果加自动寻路或者回放,逻辑能复用,不用复制粘贴一份判定。
3.3 终点判定与状态复位
到达终点的处理,最省事的做法是弹一个消息框然后重新生成迷宫。但要注意,消息框是模态的,弹出期间消息循环被接管,如果此时还有定时器在跑,容易出现“弹窗还没关,定时器又触发一次判定”的重复触发。稳妥的写法是先置一个m_bFinished标志,判定入口先查标志。
void CMazeView::OnReachGoal() { if (m_bFinished) return; // 防重复触发 m_bFinished = TRUE; AfxMessageBox(_T("到达终点")); ResetGame(); // 重新生成并复位角色 m_bFinished = FALSE; }ResetGame()里做三件事:重新填墙、调用生成、把角色放回起点,最后Invalidate()。顺序不能乱,先生成再放角色,否则角色坐标可能落在刚生成的墙上。
4. 避坑与排查:这几个坑我踩过不止一次
4.1 画面闪烁:现象是移动时整屏抖
现象:按方向键角色移动,整个迷宫区域明显闪一下,格子越多越严重。原因:默认的OnEraseBkgnd会先用背景色刷一遍,然后OnPaint再画一遍,两次绘制之间露出底色,人眼就看到闪。解决:重写OnEraseBkgnd直接返回TRUE,表示背景已处理,同时在OnPaint里用内存 DC 双缓冲。
BOOL CMazeView::OnEraseBkgnd(CDC* /*pDC*/) { return TRUE; // 屏蔽默认背景擦除 } void CMazeView::OnPaint() { CPaintDC dc(this); CRect rc; GetClientRect(&rc); CDC mem; mem.CreateCompatibleDC(&dc); CBitmap bmp; bmp.CreateCompatibleBitmap(&dc, rc.Width(), rc.Height()); CBitmap* old = mem.SelectObject(&bmp); DrawMaze(&mem); // 先画到内存 dc.BitBlt(0, 0, rc.Width(), rc.Height(), &mem, 0, 0, SRCCOPY); mem.SelectObject(old); // 恢复,避免 GDI 对象泄漏 }注意最后要SelectObject还原,否则位图对象释放时还挂在 DC 上,长期运行会累积 GDI 句柄。
4.2 按键没反应:现象是窗口能显示但方向键无效
现象:程序跑起来迷宫画得好好的,按方向键毫无反应。原因通常有两个:一是视图没有获得焦点,WM_KEYDOWN发给了别的窗口;二是消息映射宏漏写或函数签名不对。解决:先在OnInitialUpdate或OnCreate里调用SetFocus(),确认视图是焦点窗口;再核对ON_WM_KEYDOWN()是否在BEGIN_MESSAGE_MAP和END_MESSAGE_MAP之间,函数声明是否带afx_msg。如果用的是对话框模板,还要检查对话框的Tab Stop和焦点设置,别让某个控件抢了焦点。
4.3 迷宫走不通:现象是偶尔生成出孤立区域
现象:大部分迷宫正常,偶尔出现一块和主通路不连通的区域,角色永远走不到。原因多半是生成时边界条件写错,比如把nr < MAZE_H - 1写成了nr <= MAZE_H - 1,导致最外圈围墙被挖穿,或者随机方向数组没洗牌导致某些分支永远不探索。解决:生成完做一次连通性检查,从起点做一次 BFS,统计能到达的格子数,和通路总数比对,不一致就重新生成。这个检查在调试期很有用,上线前可以留着,成本很低。
4.4 重绘丢状态:现象是窗口最小化再恢复后画面错乱
现象:把窗口最小化再还原,迷宫缺一块或者角色位置不对。原因:WM_PAINT里依赖了某些只在初始化时算一次的临时变量,恢复时这些变量已经失效;或者绘制逻辑里混入了状态修改。解决:OnPaint只读状态、只画,不改任何游戏数据;所有需要持久的状态都放在成员变量里。这条是血泪经验,绘图函数里顺手改状态,短期看不出问题,窗口一重绘就翻车。
4.5 定时器叠加:现象是动画越跑越快
现象:如果加了自动演示,跑一会儿速度明显变快。原因:每次开始演示都SetTimer一次,但没KillTimer,多个同 ID 定时器叠加,消息来得越来越密。解决:SetTimer前先KillTimer同 ID,或者用一个标志位保证只注册一次。定时器 ID 建议定义成常量,别用裸数字,排查时好搜。
5. 进阶改造与验证:让这个小工程真正变成你的
把基础版跑通之后,这个工程的价值在于它是个干净的试验台。我一般会按下面的顺序加东西,每加一项都能验证一条 MFC 链路是否理解到位。
| 改造项 | 涉及技术点 | 验证方式 |
|---|---|---|
| 自动寻路演示 | BFS/DFS + 定时器逐步回放 | 观察路径是否连续、不穿墙 |
| 难度分级 | 迷宫尺寸参数化 | 改尺寸后窗口自适应 |
| 步数统计 | 状态变量 + 文本绘制 | 数字随移动实时更新 |
| 地图存档 | 文件读写(CFile / fstream) | 关闭重开能还原 |
自动寻路是最值得做的一项,因为它把“生成、判定、绘制”三条线全串起来了。用 BFS 从角色位置搜到终点,得到一条格子序列,存进std::vector<CPoint>,然后定时器每次取一个点更新角色位置并重绘。这里的关键是 BFS 的访问标记要和迷宫数组分开,别直接改m_maze,否则演示完地图就花了。
// BFS 求最短路径,返回格子序列 std::vector<CPoint> CMazeView::FindPath(CPoint start, CPoint goal) { std::vector<CPoint> path; int dist[MAZE_H][MAZE_W]; CPoint prev[MAZE_H][MAZE_W]; memset(dist, -1, sizeof(dist)); std::queue<CPoint> q; q.push(start); dist[start.y][start.x] = 0; int dirs[4][2] = {{-1,0},{1,0},{0,-1},{0,1}}; while (!q.empty()) { CPoint cur = q.front(); q.pop(); if (cur == goal) break; for (auto& d : dirs) { int nr = cur.y + d[0], nc = cur.x + d[1]; if (nr < 0 || nr >= MAZE_H || nc < 0 || nc >= MAZE_W) continue; if (m_maze[nr][nc] == 1 || dist[nr][nc] != -1) continue; dist[nr][nc] = dist[cur.y][cur.x] + 1; prev[nr][nc] = cur; q.push(CPoint(nc, nr)); } } if (dist[goal.y][goal.x] == -1) return path; // 不可达 for (CPoint p = goal; !(p == start); p = prev[p.y][p.x]) path.push_back(p); std::reverse(path.begin(), path.end()); return path; }逻辑说明:dist兼作访问标记,初始化为 -1,起点置 0。prev记录每个格子从哪来,回溯时从终点倒推到起点再反转。参数上,CPoint的x是列、y是行,和迷宫数组下标[行][列]对应,写反了路径会整体错位,这是最容易犯的错。验证方法很直接:把路径画成半透明色块叠在迷宫上,肉眼扫一遍有没有穿墙,再对比步数和 BFS 距离是否一致。
做完这些,我对这类小工程有个固定习惯:每次改完生成算法或移动判定,先把迷宫尺寸调到最小(比如 5×5)跑一遍,小尺寸下所有边界和死角都暴露得快,比在大迷宫里瞎找高效得多。从那以后我每次动这类格子逻辑,都强制先用最小尺寸过一遍再放大。希望这份拆解帮到你,把这份 MFC 迷宫工程真正用起来。
本文还有配套的精品资源,点击获取