ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MFC实战:手搓迷宫游戏,掌握消息循环与GDI绘图

MFC实战:手搓迷宫游戏,掌握消息循环与GDI绘图

简介:这是一份基于MFC框架开发的简易迷宫游戏源码资源,面向正在学习C++、Windows程序设计和MFC入门的开发者。项目通过文档/视图架构组织代码,利用CWnd进行窗口管理,结合CPen、CBrush等GDI类完成迷宫地图绘制,并借助ON_COMMAND等消息宏实现键盘或鼠标交互,适合作为理解事件驱动编程与面向对象设计的练手案例。压缩包共45个文件,约2.13MB,包含7个h头文件、6个cpp源文件、6个obj编译中间文件,以及png、bmp、ico等图片与图标资源,另有rc资源脚本、dsp/dsw工程文件和可执行exe,覆盖从源码到运行结果的完整工程结构。资源中附有分步骤的png示意图,展示游戏思路与自定义迷宫的创建过程,玩家类与迷宫数据结构分离清晰,便于读者分析DFS或BFS路径搜索的实现方式。目前已有378人学习下载,适合希望从零梳理MFC项目组织、消息映射与图形绘制流程的初学者参考。

1. 用 MFC 手搓一个迷宫游戏:从对话框到键盘响应的最小闭环

很多人第一次接触 Windows 桌面开发,都是从 MFC 开始的。但真正让人卡住的往往不是语法,而是“消息循环到底怎么转”“键盘按下去了窗口怎么没反应”。这个用 MFC 做的简单迷宫游戏,恰好是一个能把这些机制串起来的最小可运行项目。它不依赖任何第三方引擎,纯靠 CDC 绘图、键盘消息和定时器,就能跑出一个可玩的小游戏。适合刚学完 C++ 语法、想找一个能直接编译运行的 MFC 练手项目的开发者,也适合想回头补一补 Windows 消息机制的老手。整个项目结构不复杂,但麻雀虽小,消息映射、双缓冲绘图、碰撞检测这些该有的都有。

2. 项目结构与技术选型:为什么是 MFC 而不是 Qt

2.1 技术栈拆解

这个项目用的是最经典的 MFC 对话框程序(Dialog-based),不是单文档/多文档架构。选对话框程序的原因很直接:迷宫游戏本质上就是一个固定大小的窗口,不需要菜单栏、工具栏、状态栏那一套框架,对话框模板反而更轻。

核心依赖只有三块:

  • MFC 库:提供 CWnd、CDC、CPaintDC 这些基础类,负责窗口创建和绘图上下文。
  • GDI 绘图:用 CDC 的 Rectangle、Ellipse、TextOut 等接口画墙、画玩家、画终点。
  • Windows 消息机制:WM_KEYDOWN 处理方向键,WM_TIMER 驱动游戏循环,WM_PAINT 负责重绘。

常见做法是把游戏逻辑和界面代码分开,但在这个体量的项目里,很多人会直接写在对话框类的成员函数里。我一般会建议至少把地图数据和渲染拆成两个函数,不然后面改地图会很痛苦。

2.2 目录结构说明

项目文件不多,典型的 MFC 工程结构:

MazeGame/ ├── MazeGame.sln ├── MazeGame/ │ ├── MazeGame.h // 主对话框类声明 │ ├── MazeGame.cpp // 主对话框类实现,游戏逻辑核心 │ ├── MazeGame.rc // 资源文件,对话框模板在这里 │ ├── resource.h // 资源 ID 定义 │ ├── stdafx.h // 预编译头 │ └── res/ │ └── MazeGame.ico

真正要看的只有MazeGameDlg.h和MazeGameDlg.cpp两个文件。地图数据一般用一个二维数组表示,0 代表通路,1 代表墙,2 代表终点。玩家位置用两个 int 存行列坐标。

2.3 对话框初始化与地图加载

在OnInitDialog里做三件事:初始化地图数组、设置玩家起点、启动定时器。

BOOL CMazeGameDlg::OnInitDialog() { CDialogEx::OnInitDialog(); // 初始化地图:1 表示墙,0 表示通路,2 表示终点 int mapData[MAP_ROWS][MAP_COLS] = { {1,1,1,1,1,1,1,1,1,1}, {1,0,0,0,1,0,0,0,0,1}, {1,0,1,0,1,0,1,1,0,1}, {1,0,1,0,0,0,1,0,0,1}, {1,0,1,1,1,0,1,0,1,1}, {1,0,0,0,1,0,0,0,0,1}, {1,1,1,0,1,1,1,1,0,1}, {1,0,0,0,0,0,0,0,0,1}, {1,0,1,1,1,1,1,1,2,1}, {1,1,1,1,1,1,1,1,1,1} }; memcpy(m_map, mapData, sizeof(mapData)); m_playerRow = 1; // 玩家起始行 m_playerCol = 1; // 玩家起始列 m_bGameOver = FALSE; SetTimer(TIMER_GAME, 100, NULL); // 100ms 刷新一次 return TRUE; }

这里MAP_ROWS和MAP_COLS是宏定义,一般设成 10。定时器 ID 用TIMER_GAME这种有名字的常量,别直接写 1,后面加第二个定时器时会后悔。地图数组用memcpy拷到成员变量里,是因为局部数组出了函数就没了,而OnPaint里还要用它来画墙。

注意:OnInitDialog返回值必须是 TRUE,除非你手动调用了SetFocus并想返回 FALSE。这个坑我见过不止一个人踩。

3. 核心模块拆解:绘图、键盘与碰撞检测

3.1 双缓冲绘图避免闪烁

直接在每个OnPaint里用CPaintDC画图,窗口刷新时会闪得厉害。标准做法是双缓冲:先在内存 DC 里画好整张图,再一次性 BitBlt 到屏幕。

void CMazeGameDlg::OnPaint() { CPaintDC dc(this); CRect rect; GetClientRect(&rect); // 创建内存 DC 和兼容位图 CDC memDC; memDC.CreateCompatibleDC(&dc); CBitmap bmp; bmp.CreateCompatibleBitmap(&dc, rect.Width(), rect.Height()); memDC.SelectObject(&bmp); // 先填充背景 memDC.FillSolidRect(&rect, RGB(240, 240, 240)); // 画迷宫 for (int i = 0; i < MAP_ROWS; i++) { for (int j = 0; j < MAP_COLS; j++) { int x = j * CELL_SIZE; int y = i * CELL_SIZE; if (m_map[i][j] == 1) { memDC.FillSolidRect(x, y, CELL_SIZE, CELL_SIZE, RGB(60, 60, 60)); } else if (m_map[i][j] == 2) { memDC.FillSolidRect(x, y, CELL_SIZE, CELL_SIZE, RGB(0, 180, 0)); } } } // 画玩家 int px = m_playerCol * CELL_SIZE; int py = m_playerRow * CELL_SIZE; memDC.FillSolidRect(px + 4, py + 4, CELL_SIZE - 8, CELL_SIZE - 8, RGB(220, 60, 60)); // 一次性贴到屏幕 dc.BitBlt(0, 0, rect.Width(), rect.Height(), &memDC, 0, 0, SRCCOPY); }

CELL_SIZE是每个格子的像素大小,一般设 40 或 50。FillSolidRect比Rectangle少画边框,性能更好。玩家方块留了 4 像素边距,视觉上不会和墙贴死。内存 DC 用完不需要手动 Delete,CDC 和 CBitmap 的析构函数会处理,但如果你在循环里反复创建,那就要注意了。

3.2 键盘消息处理

MFC 对话框默认不接收方向键的WM_KEYDOWN,因为方向键会被用来在控件之间切换焦点。解决办法是重写PreTranslateMessage。

BOOL CMazeGameDlg::PreTranslateMessage(MSG* pMsg) { if (pMsg->message == WM_KEYDOWN && !m_bGameOver) { int newRow = m_playerRow; int newCol = m_playerCol; switch (pMsg->wParam) { case VK_UP: newRow--; break; case VK_DOWN: newRow++; break; case VK_LEFT: newCol--; break; case VK_RIGHT: newCol++; break; default: return CDialogEx::PreTranslateMessage(pMsg); } // 碰撞检测:撞墙就不动 if (m_map[newRow][newCol] != 1) { m_playerRow = newRow; m_playerCol = newCol; // 到达终点 if (m_map[newRow][newCol] == 2) { m_bGameOver = TRUE; KillTimer(TIMER_GAME); MessageBox(_T("恭喜通关!"), _T("提示"), MB_OK); } Invalidate(); // 触发重绘 } return TRUE; // 消息已处理,不再传递 } return CDialogEx::PreTranslateMessage(pMsg); }

PreTranslateMessage是在消息进入窗口过程之前拦截的,所以能拿到方向键。Invalidate()会触发OnPaint,但不会立即执行,Windows 会合并多次无效区域。碰撞检测只判断了目标格子是不是墙,没有做边界检查——因为地图最外圈全是墙,玩家走不出去。如果你改地图时把外圈拆了,记得补上边界判断,不然数组越界就是另一个故事了。

3.3 定时器与游戏循环

这个项目用SetTimer驱动一个简单的游戏循环,主要用来做两件事:刷新画面和处理计时逻辑。

void CMazeGameDlg::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent == TIMER_GAME) { // 这里可以加计时、动画等逻辑 // 当前版本只做重绘 Invalidate(); } CDialogEx::OnTimer(nIDEvent); }

100ms 的间隔对于迷宫游戏来说足够了,人眼看起来是连续的。如果你要做移动动画,可以把间隔调到 30ms 左右,然后在OnTimer里更新玩家像素偏移量。但那样就得把玩家坐标从格子坐标改成像素坐标,复杂度会上一个台阶。这个版本保持格子对齐,逻辑最简单。

提示:KillTimer一定要在OnDestroy或OnCancel里再调一次,防止窗口关闭后定时器还在跑。

4. 编译运行与常见问题排查

4.1 环境配置与编译步骤

这个项目需要 Visual Studio,推荐 2017 及以上版本,社区版就够。安装时记得勾选“使用 C++ 的桌面开发”工作负载,MFC 组件在单个组件里手动勾上。

编译步骤:

  1. 用 VS 打开MazeGame.sln。
  2. 确认解决方案平台是 x86 或 x64,Debug 或 Release 都行。
  3. 如果提示找不到 MFC 头文件,右键项目 → 属性 → 配置属性 → 常规 → “使用 MFC”改成“在共享 DLL 中使用 MFC”。
  4. F5 运行。

如果编译报错fatal error C1083: 无法打开包括文件: "afxwin.h",就是 MFC 组件没装。打开 VS Installer,修改 → 单个组件 → 搜索 MFC → 勾选对应版本。

4.2 常见问题排查

现象一:方向键按了没反应。原因:对话框默认把方向键当焦点切换处理了。 解决:确认PreTranslateMessage已经重写,并且返回了 TRUE。如果还是不行,检查对话框上是不是有按钮控件抢了焦点,可以在OnInitDialog里把焦点设到对话框本身。

现象二:窗口闪烁严重。原因:OnPaint里直接往CPaintDC上画,没有用双缓冲。 解决:按 3.1 的写法加内存 DC。另外在OnEraseBkgnd里直接返回 TRUE,阻止系统擦背景。

现象三:玩家能穿墙。原因:碰撞检测写成了if (m_map[newRow][newCol] == 0)才移动,但终点格是 2,也会被挡住。 解决:判断条件改成!= 1,这样通路和终点都能走。

现象四:通关后还能继续走。原因:m_bGameOver标志没在键盘处理里判断。 解决:在PreTranslateMessage开头加!m_bGameOver条件,通关后直接忽略按键。

现象五:Release 模式下地图显示错乱。原因:局部数组mapData在 Debug 下可能被零初始化,Release 下不会。 解决:确保memcpy的长度用sizeof(mapData),并且成员数组大小和局部数组完全一致。

5. 二次开发改造:从固定地图到可编辑关卡

这个项目最值得改的地方就是地图。现在地图是硬编码在OnInitDialog里的,想换个迷宫就得重新编译。我一般会把它改成从文本文件读取,这样关卡设计就独立出来了。

void CMazeGameDlg::LoadMapFromFile(LPCTSTR filePath) { CStdioFile file; if (!file.Open(filePath, CFile::modeRead | CFile::typeText)) { AfxMessageBox(_T("地图文件打开失败")); return; } CString line; int row = 0; while (file.ReadString(line) && row < MAP_ROWS) { for (int col = 0; col < MAP_COLS && col < line.GetLength(); col++) { m_map[row][col] = line[col] - _T('0'); // 字符转数字 } row++; } file.Close(); }

地图文件格式就是每行一串数字,比如1111111111表示一行墙。这样改关卡不用动代码,用记事本就能编。CStdioFile是 MFC 自带的文件类,处理文本比ifstream在 Unicode 环境下更省心。

另一个改造方向是加计时和步数统计。在OnTimer里累加时间,在键盘处理里累加步数,然后在OnPaint里用TextOut画到窗口顶部。注意TextOut要放在双缓冲的memDC上,不然文字会闪。

// 在 OnPaint 的双缓冲绘制部分加入 CString info; info.Format(_T("步数: %d 时间: %d 秒"), m_stepCount, m_elapsedSeconds); memDC.SetBkMode(TRANSPARENT); memDC.TextOut(10, 10, info);

SetBkMode(TRANSPARENT)让文字背景透明,不然会有一个白底方块盖住迷宫。m_elapsedSeconds在OnTimer里每 10 次 tick 加 1,因为定时器是 100ms。

还有一个容易忽略的点:窗口大小。对话框模板默认大小可能和地图尺寸对不上,导致迷宫画不全。我一般会在OnInitDialog里根据MAP_ROWS * CELL_SIZE和MAP_COLS * CELL_SIZE动态调整窗口客户区大小。

// 在 OnInitDialog 末尾调整窗口大小 CRect rect(0, 0, MAP_COLS * CELL_SIZE, MAP_ROWS * CELL_SIZE + 40); AdjustWindowRect(&rect, GetStyle(), FALSE); SetWindowPos(NULL, 0, 0, rect.Width(), rect.Height(), SWP_NOMOVE | SWP_NOZORDER);

+40是给顶部信息栏留的空间。AdjustWindowRect会把客户区尺寸换算成包含边框和标题栏的窗口尺寸。这一步不做的话,地图右下角会被截掉,而且不同 DPI 下表现还不一样。

从那以后我每次写 MFC 绘图程序,都会先把双缓冲和窗口尺寸自适应这两步做掉,再写业务逻辑。不然等地图画出来再回头改,OnPaint里已经堆了一堆代码,动起来很容易翻车。希望这个迷宫项目能帮你把 MFC 的消息机制和 GDI 绘图跑通,后面再改什么功能,心里都有底。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进