简介:这是一份面向C++初学者与Windows桌面开发爱好者的MFC实战学习项目,以经典塔防游戏《植物大战僵尸》为原型,帮助读者理解如何用MFC类库搭建游戏界面、处理消息事件并组织游戏逻辑。资源共15个文件,包含6个h头文件、3个cpp源文件,以及vcxproj工程文件、sln解决方案、rc与rc2资源脚本、filters筛选器和ico图标等,压缩包约71KB,结构完整可直接用Visual Studio打开编译。项目围绕界面布局、消息响应、植物种植与僵尸移动等核心模块展开,是理解Windows程序结构与C++面向对象思想的典型案例。目前已有102人学习下载,适合希望从零接触MFC游戏开发、对照源码梳理工程组织方式的读者参考借鉴。
1. 从一份 MFC 版植物大战僵尸源码说起:它到底能跑出什么
如果你最近在搜「c++小游戏」「mfc教程」或者「植物大战僵尸 完整代码」,大概率会刷到各种 HTML 版、Scratch 版,甚至有人直接甩一句「给我生成一个植物大战僵尸的代码」。但真正用 C++ 加 MFC 从零搭一个能编译、能跑、能点开始、能种植物、僵尸会走路的 Windows 桌面工程,其实并不多见。这份pvs-z_-mfc-master就是这样一个东西:它把《植物大战僵尸》的核心玩法塞进了一个标准 MFC 对话框工程里,用PVsZ_MFCDlg.cpp承载游戏主循环,用res目录管图片资源,用PVsZ_MFC.sln让你在 Visual Studio 里直接打开就能编译。它不适合拿去对标原版商业游戏,但特别适合两类人:一是刚学完 C++ 类与继承、想找个能看见画面的项目练手的;二是做 Windows 桌面开发、想搞清楚 MFC 消息循环怎么和游戏帧刷新结合的。下面我就按「拿到源码先干什么、界面和逻辑怎么拆、资源怎么塞、坑在哪」的顺序,把这份工程拆一遍。
2. 把工程跑起来:环境、编译与第一个对话框
2.1 先确认你手里的 VS 能认这个工程
这份源码的入口是PVsZ_MFC.sln,配套PVsZ_MFC.vcxproj和PVsZ_MFC.vcxproj.filters。它不是一个 CMake 工程,也不是 vcxproj 之外还能用别的 IDE 打开的格式,所以第一步就是确认 Visual Studio 版本。工程里出现了targetver.h、pch.h、framework.h这一套,说明它是按较新的 MFC 工程模板生成的,至少需要 VS2017 以上,我一般直接用 VS2019 或 VS2022 打开。安装 VS 的时候必须勾选「使用 C++ 的桌面开发」里的「MFC 最新版 v143 生成工具」和「Windows 10 SDK」,否则打开解决方案会提示找不到afxwin.h之类的头文件。
打开之后先别急着按 F5。看一眼解决方案平台,默认可能是Debug|x64或Debug|Win32,两个都能编,但如果你机器上只装了 x64 的 MFC 库,Win32 会报链接错误。常见做法是统一切成x64,然后右键解决方案选「重新生成」。第一次编译如果报Cannot open include file: 'pch.h',不是文件丢了,而是PVsZ_MFC.cpp里预编译头设置没对上,检查项目属性 → C/C++ → 预编译头,确认是「使用(/Yu)」并且头文件名为pch.h。
2.2 对话框工程的主循环藏在哪
MFC 对话框程序没有传统WinMain里那种while(GetMessage)的显式循环,它靠CWinApp::Run()内部的消息泵驱动。这份工程把游戏逻辑挂在PVsZ_MFCDlg上,所以你要找的「游戏主循环」其实是两个东西的组合:一个是OnInitDialog()里做的初始化,另一个是OnTimer()里按固定间隔触发的帧更新。典型写法是这样:
// PVsZ_MFCDlg.cpp 里常见的定时器启动方式 BOOL CPVsZ_MFCDlg::OnInitDialog() { CDialogEx::OnInitDialog(); // 加载植物、僵尸、子弹等图片资源 m_bg.Load(_T("res\\background.bmp")); m_plant.Load(_T("res\\peashooter.bmp")); // 设置定时器,约 30ms 一帧,对应 33 FPS SetTimer(TIMER_GAME_LOOP, 30, nullptr); return TRUE; } void CPVsZ_MFCDlg::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent == TIMER_GAME_LOOP) { UpdateGame(); // 更新僵尸位置、子弹碰撞、阳光数值 Invalidate(); // 触发重绘,最终走到 OnPaint } CDialogEx::OnTimer(nIDEvent); }这里SetTimer的第二个参数是毫秒,30 表示大约每秒 33 次刷新。参数改小会让游戏更顺但 CPU 占用上升,改大到 50 以上僵尸移动会明显卡顿。UpdateGame()是你自己写的逻辑入口,Invalidate()负责把客户区标记为脏,随后OnPaint()里用CDC把背景和所有对象画出来。注意 MFC 的OnPaint默认会擦背景,直接画会闪,所以工程里一般会在OnPaint里用内存 DC 做双缓冲,这个后面第 4 章会细说。
2.3 资源文件怎么和代码对上
工程里res目录和PVsZ_MFC.rc、resource.h、PVsZ_MFC.ico、PVsZMFC.rc2是一套。.rc是资源脚本,resource.h里定义资源 ID,比如对话框 IDIDD_PVSZ_MFC_DIALOG。如果你替换了res里的图片但画面没变,先检查代码里加载的路径是不是写死的相对路径。MFC 程序默认工作目录是 exe 所在目录,也就是x64\Debug\,而res在工程根目录,所以要么把res拷到输出目录,要么在项目属性 → 调试 → 工作目录里设成$(ProjectDir)。我一般选后者,省得每次改图都要复制一遍。
3. 界面与游戏逻辑怎么拆:MFC 控件、消息映射与对象管理
3.1 用对话框当画布,控件只做辅助
这份工程的主体是CPVsZ_MFCDlg,它继承CDialogEx。很多人第一次看会疑惑:游戏画面为什么不用CView或者自绘窗口?因为对话框工程上手最快,OnPaint里拿到的CDC*直接就能画,而且按钮、静态文本这些控件拖上去就能用,做开始菜单、暂停、得分显示特别方便。常见做法是把对话框客户区当成游戏画布,所有植物、僵尸、子弹都用CDC::BitBlt或StretchBlt贴图,而不是给每个对象创建一个子控件。子控件一多,消息路由和重绘区域会变得很难管,性能也差。
坐标系统要注意:MFC 默认客户区左上角是(0,0),x 向右、y 向下,和很多游戏引擎一致。但如果你在OnPaint里直接用CPaintDC dc(this),画出来的坐标是相对于客户区的;如果用了ScreenToClient转换鼠标坐标,记得在OnLButtonDown里先转再判断点到了哪一格。植物种植一般按网格走,比如每格 80×100 像素,那么col = point.x / 80、row = point.y / 100,再查这个格子有没有被占。
3.2 消息映射:鼠标点击怎么变成种植物
MFC 靠消息映射表把 Windows 消息接到成员函数上。对话框头文件里会有DECLARE_MESSAGE_MAP(),cpp 里用BEGIN_MESSAGE_MAP和END_MESSAGE_MAP包起来。你要响应鼠标左键,就得加ON_WM_LBUTTONDOWN(),然后实现OnLButtonDown。典型流程是:先判断当前选中的植物类型,再算格子,再检查阳光够不够,最后把植物对象塞进容器。
// 消息映射里注册 BEGIN_MESSAGE_MAP(CPVsZ_MFCDlg, CDialogEx) ON_WM_PAINT() ON_WM_TIMER() ON_WM_LBUTTONDOWN() END_MESSAGE_MAP() void CPVsZ_MFCDlg::OnLButtonDown(UINT nFlags, CPoint point) { // 把屏幕坐标转成客户区坐标,防止窗口移动后点偏 CPoint pt = point; ScreenToClient(&pt); int col = pt.x / GRID_W; int row = pt.y / GRID_H; // 只有选中植物且阳光足够才种 if (m_curPlant != PLANT_NONE && m_sun >= PLANT_COST[m_curPlant]) { if (!IsCellOccupied(row, col)) { m_plants.push_back(CPlant(m_curPlant, row, col)); m_sun -= PLANT_COST[m_curPlant]; } } CDialogEx::OnLButtonDown(nFlags, point); }ScreenToClient这一步是血泪经验:如果你不做转换,窗口一拖动,点击位置就全错。GRID_W、GRID_H是格子宽高,PLANT_COST是每种植物消耗的阳光数组。m_plants用std::vector<CPlant>存,遍历时用迭代器删除死亡对象,别在for循环里直接erase当前元素,否则迭代器失效,这是 C++ 容器最常见的翻车点之一。
3.3 游戏对象用类继承还是结构体数组
这份工程里植物、僵尸、子弹大概率是各自独立的类,或者至少是带类型字段的结构体。从PVsZ_MFCDlg.h的命名风格看,它更偏向用类封装。我的建议是:如果只是学习,用struct加一个type枚举就够了,省去大量继承和虚函数开销;如果想让代码更「面向对象」,可以抽一个CGameObject基类,带Update()和Draw(CDC*)两个虚函数,植物和僵尸各自重写。但要注意,MFC 的CObject自带序列化和运行时类型信息,如果你不需要存盘,没必要让游戏对象继承CObject,那会引入额外的宏和内存布局,反而容易出问题。
僵尸移动逻辑一般放在UpdateGame()里统一遍历:每个僵尸按速度增加 x 坐标,碰到植物就停下啃,血量归零就移除。子弹和僵尸的碰撞用矩形相交判断,CRect::IntersectRect就能做。这里有个性能边界:当僵尸和子弹数量都上百时,两两判断是 O(n²),MFC 本身不慢,但你的写法会拖垮它。常见优化是按行分桶,或者只让子弹检测同一行附近的僵尸。
4. 避坑与排查:编译、闪烁、资源丢失的常见问题
4.1 编译报afxwin.h找不到
现象是打开解决方案后一堆红色波浪线,提示Cannot open include file: 'afxwin.h'。原因不是你代码写错了,而是 VS 安装时没勾 MFC 组件。解决方法是打开 Visual Studio Installer,点「修改」,在「单个组件」里搜MFC,把对应版本的「适用于最新 v143 生成工具的 C++ MFC」勾上,同时确认「Windows 10 SDK」也装了。装完重启 VS,重新生成即可。
4.2 画面闪烁严重,僵尸像在抽帧
现象是游戏跑起来后背景和对象一闪一闪,鼠标移动时更明显。原因是OnPaint里直接用CPaintDC逐次绘制,每次都会先用背景色擦除客户区。解决办法是双缓冲:先在内存 DC 里把整帧画完,再一次性BitBlt到屏幕。代码结构大致是创建兼容 DC 和兼容位图,把OnPaint里的绘制目标从CPaintDC换成内存 DC,最后拷贝。注意内存 DC 和位图要在OnSize或初始化时按客户区大小重建,窗口缩放后不重建会画到外面去。
4.3 图片加载失败但编译通过
现象是程序能跑,但植物和僵尸都是空白或者黑块。原因通常是路径问题:代码里写的是res\\xxx.bmp,而 exe 的工作目录是x64\Debug,那里没有res。解决方法是项目属性 → 调试 → 工作目录改成$(ProjectDir),或者把res目录整个复制到输出目录。另外注意LoadBitmap对 BMP 格式有要求,必须是 Windows 位图格式,用 PNG 改后缀是不行的,得先用画图或 PS 转成 24 位 BMP。
4.4 定时器越跑越卡,阳光数值不更新
现象是游戏刚开始还行,玩几分钟后僵尸移动变慢,阳光数字也不跳了。原因可能是OnTimer里做了耗时操作,比如每次都重新加载图片,或者UpdateGame里用了大量new/delete没释放。解决方法是把资源加载移到OnInitDialog,游戏对象用容器管理,死亡对象标记后统一清理。另外检查SetTimer是否被重复调用,多次调用同一个 ID 会重置计时器,但如果你在OnTimer里又SetTimer,就会陷入嵌套。常见做法是只在初始化时设一次,OnTimer里只做更新和重绘。
4.5 鼠标点击位置偏移
现象是点植物卡片没反应,或者种下去的植物不在鼠标位置。原因除了前面说的没做ScreenToClient,还有一种可能是对话框有边框和标题栏,客户区原点不在窗口左上角。解决方法是统一用GetClientRect拿客户区大小,鼠标坐标一律先ScreenToClient,再按格子取整。如果还是偏,打印一下pt.x和pt.y,看看是不是被 DPI 缩放影响了。高 DPI 屏上,VS 默认可能开了 DPI 感知,需要在清单文件里关掉或者做缩放换算。
5. 进阶玩法:把这份工程改成你自己的塔防模板
5.1 用状态机管游戏流程
现在这份工程大概率是「一打开就开打」,没有开始菜单、暂停、失败结算。想让它像个完整小游戏,最省事的做法是加一个enum GameState { MENU, PLAYING, PAUSED, GAME_OVER },在OnTimer里根据状态决定是更新游戏还是只重绘菜单。菜单可以用CDC::TextOut直接画文字,也可以用 MFC 按钮控件,但按钮会抢焦点,游戏里一般还是自绘。状态切换时记得KillTimer和SetTimer配对,暂停时停掉游戏定时器,恢复时重新设,不然暂停期间僵尸还在走。
5.2 把关卡数据抽成配置文件
硬编码僵尸波次和植物成本,改一次就要重新编译,很不划算。常见做法是写一个简单的文本配置,比如每行wave=1, zombie=normal, count=5, interval=3000,程序启动时读进std::vector<Wave>。MFC 里可以用CStdioFile按行读,也可以用std::ifstream。解析时注意跳过空行和#注释,数字转换用_ttoi或std::stoi。这样你就能在不碰 C++ 代码的情况下调难度,对做课程设计或者给别人演示特别有用。
5.3 验证改动是否生效的笨办法
改完代码别只看编译过没过,要实际跑一遍关键路径。我一般会按这个清单走:启动后看背景是否正常加载;点植物卡片看阳光是否扣减;种下去看格子是否被占;等僵尸出来看是否移动、是否啃植物;植物死亡后看是否从容器移除;关掉窗口看是否有内存泄漏报错。VS 自带的诊断工具可以在调试 → 窗口 → 显示诊断工具里看内存曲线,如果每次重开一局内存都涨一截,大概率是new了没delete,或者vector里存了指针没清理。MFC 对象比如CBitmap、CDC记得在析构里DeleteObject或DeleteDC,不然 GDI 对象泄漏到一定数量程序会直接画不出东西。
5.4 一个具体技巧:用WM_ERASEBKGND干掉背景擦除
双缓冲之外,还有一个立竿见影的减闪手段:重写OnEraseBkgnd直接返回TRUE,告诉 Windows 你已经自己处理了背景,不用它再擦一遍。配合双缓冲,画面会稳很多。代码就一行:
BOOL CPVsZ_MFCDlg::OnEraseBkgnd(CDC* pDC) { // 返回 TRUE 表示背景已由 OnPaint 的双缓冲处理,系统不再擦除 return TRUE; }注意这个函数返回TRUE后,如果你OnPaint里没画满整个客户区,残留的旧画面会留在那里。所以双缓冲的位图必须按客户区完整大小创建,每帧先填背景色再画对象。这个习惯我每次做 MFC 自绘界面都会强制走一遍,省得后面花时间查闪烁。希望这份拆解能帮你把这份 MFC 版植物大战僵尸顺利跑起来,并且知道每一步为什么这么写。
本文还有配套的精品资源,点击获取