简介:一份基于MFC的C++《植物大战僵尸》复刻项目,面向具备基础C++语法、想了解Windows桌面游戏开发的初学者。资源包含完整工程源码,共15个文件,以6个头文件和3个CPP源文件为主,辅以解决方案与项目配置(sln、vcxproj)、窗口资源脚本(rc、rc2、filters)及程序图标(ico),压缩包仅71KB,体量小巧,源码结构清晰,便于逐文件学习。项目演示了MFC对话框程序的基本搭建,涵盖菜单界面设计、按钮与鼠标消息响应、植物与僵尸的交互逻辑、游戏资源加载,以及双缓冲绘图等常用优化手段。由于原版游戏机制复杂,该版本更侧重教学演示,能帮助读者理解Windows消息驱动框架、C++面向对象封装思路和MFC控件使用方式。目前已有102人学习下载,适合作为进入游戏开发前的练手与课程设计参考。
1. MFC 版植物大战僵尸:一个能跑的教学型塔防工程
这套资源不是那种只有截图、没有源码的演示包,而是一份用 C++ 与 MFC 写出的《植物大战僵尸》复刻工程。压缩包里包含完整的 .sln 解决方案、对话框实现代码、资源脚本和图标,拿到手就能用 Visual Studio 打开编译。它的画面表现没法跟商业游戏比,但教学层面的价值很实在:对话框程序的生命周期、定时器驱动的游戏循环、GDI 双缓冲绘图、指针对象的创建与释放,这些 C++ 程序员在 Windows 平台上迟早要面对的硬骨头,全都浓缩在这一个工程里。适合刚接触 MFC 的初学者,也适合想快速给别人演示一个“C++ 写的可运行小游戏”的从业者。全程不依赖 DirectX 或第三方引擎,只需要一台装了 Visual Studio 的 Windows 机器。
2. 工程结构拆解:从解决方案到对话框的骨架
拿到压缩包先别急着双击 .sln,花五分钟把目录结构过一遍,后面排错的效率会高很多。这个工程的根目录是一套标准的 MFC 对话框项目布局。每个文件是干什么的,我先把清单列出来,对照着看心里有底。
2.1 文件清单与职责划分
pvs-z_-mfc-master/ ├── PVsZ_MFC.sln # 解决方案文件,VS 双击这个就能打开整个工程 ├── PVsZ_MFC.vcxproj # 项目文件,编译配置、源文件清单、平台工具集 ├── PVsZ_MFC.vcxproj.filters # 资源管理器的虚拟目录分组,只影响显示不影响编译 ├── PVsZ_MFC.cpp # 应用入口,定义 CWinApp 派生类和 theApp 全局对象 ├── PVsZ_MFC.h # 应用类的头文件 ├── PVsZ_MFCDlg.cpp # 主对话框类实现,游戏逻辑与绘制全在这里 ├── PVsZ_MFCDlg.h # 主对话框类声明 ├── framework.h # MFC 必需的框架头文件,一般不用动 ├── pch.h / pch.cpp # 预编译头,加速编译 ├── targetver.h # 目标平台版本宏,控制 Windows SDK 兼容性 ├── res/ │ ├── PVsZ_MFC.ico # 应用程序图标 │ └── PVsZMFC.rc2 # 附加资源脚本,存放非可视化资源 └── PVsZMFC.rc # 资源脚本,对话框模板、图标、版本信息全在这这些文件里,真正决定游戏逻辑的只有两个:PVsZ_MFCDlg.cpp 和 PVsZ_MFCDlg.h。MFC 框架把窗口创建、消息循环这些底层 Windows API 调用都封装掉了,你可以把精力集中在游戏逻辑而不是 CreateWindowEx 上。resource.h 里定义的 IDD_PVSZ_MFC_DIALOG 等宏是资源的身份证;对话框布局则躺在 PVsZMFC.rc 里,VS 的可视化资源编辑器可以直接改。
和商业版本相比,这个工程里有一个值得注意的取舍:资源文件里只有图标资源,没有植物和僵尸的贴图文件。这说明它的渲染走的是 GDI 绘图路线,用 Rectangle、Ellipse、BitBlt 这些函数在窗口上现画。好处是工程文件干净,不依赖外部美术资源,解压就能编译;坏处是画面表现力有限,而且每种新物体都要多写一段绘制代码。这类教学工程通常这么选,目的是把复杂度控制在 C++ 类和消息处理这个范畴里。
2.2 MFC 对话框程序的生命周期
一个 MFC 对话框程序跑起来,表面上只是一闪而过的窗口,背后是一系列有序调用。以这个工程为例,入口是 PVsZ_MFC.cpp 里的 theApp 全局对象,不是 main 函数。MFC 把 WinMain 藏在框架库里了,你看到的第一个自定义函数是 InitInstance。
// PVsZ_MFC.cpp 核心结构 BEGIN_MESSAGE_MAP(CPVsZ_MFCApp, CWinApp) END_MESSAGE_MAP() CPVsZ_MFCApp theApp; // 全局对象,程序真正的入口点 BOOL CPVsZ_MFCApp::InitInstance() { // 创建主对话框对象 CPVsZ_MFCDlg dlg; m_pMainWnd = &dlg; // 模态方式运行,进入消息循环 dlg.DoModal(); return FALSE; // 对话框退出后结束消息循环,程序退出 }这里的 DoModal() 是核心。它弹出模态对话框并进入消息循环,窗口不会像控制台程序那样顺序执行完就退出,而是挂在循环里等着消息送达。用户点击、按键、定时器到期,这些事件都变成消息,被分派到 PVsZ_MFCDlg 类里对应的消息处理函数。
消息映射表是 MFC 理解事件的枢纽。PVsZ_MFCDlg.cpp 里会出现类似的宏片段:
BEGIN_MESSAGE_MAP(CPVsZ_MFCDlg, CDialogEx) ON_WM_PAINT() // 映射 WM_PAINT 到 OnPaint ON_WM_TIMER() // 映射 WM_TIMER 到 OnTimer ON_WM_LBUTTONDOWN() // 映射鼠标左键按下 ON_WM_ERASEBKGND() // 映射 WM_ERASEBKGND END_MESSAGE_MAP()每增加一个消息处理,就要在头文件里加一个 afx_msg 声明的成员函数,同时在映射表里加一行宏。漏了映射表的条目,函数写得再对也不会被调用,这是 MFC 新手最容易踩的隐形 bug,编译器不会给任何警告。理解了生命周期,你就明白为什么游戏初始化要写在 OnInitDialog 而不是构造函数里:对话框模板在构造函数之后才加载,控件还没创建,GetDlgItem 返回空指针。
2.3 资源脚本与界面控件的对应关系
PVsZMFC.rc 是最容易被忽略但实际很关键的文件。它用文本描述对话框的初始布局:控件位置、尺寸、样式、图标 ID、版本信息。VS 的对话框编辑器改动后,最终也是写回这个文件。如果打开资源脚本发现对话框模板缺失,工程要么编译失败、要么运行时窗口空白。
一个简化版的对话框模板片段长这样,控件的位置和尺寸都能直接在文本里看到:
IDD_PVSZ_MFC_DIALOG DIALOGEX 0, 0, 800, 600 STYLE DS_SETFONT | DS_MODALFRAME | WS_POPUP | WS_VISIBLE | WS_CAPTION | WS_SYSMENU CAPTION "PVsZ MFC 版" FONT 8, "MS Shell Dlg", 0, 0, 0x1 BEGIN CTEXT "阳光: 50", IDC_SUNSHINE_TEXT, 7, 7, 80, 14 ENDMFC 对话框资源与控件通过 ID 关联,统一编号放在 resource.h。同一套工程里 IDD_ 开头的是对话框,IDC_ 开头的是控件,IDR_ 开头的是菜单或图标。命名规则看多了自然形成肌肉记忆。想要加一个按钮,需要改三处:在 resource.h 里新增一个 IDC_ 宏,在 .rc 对话框模板里加 CONTROL 语句,再在类和消息映射表里接上处理函数。很多教程默认你会用可视化编辑器拖控件,但直接改 .rc 文件有时比拖拽更快,尤其在批量调整控件位置的时候。
3. 游戏循环与双缓冲渲染:把塔防逻辑塞进消息系统
从组织代码的角度看,MFC 对话框程序跟控制台游戏最大的区别是没有 while(1) 主循环。替代方案是用定时器消息驱动“每帧逻辑”。这一章从定时器开始,逐步拆解种植、移动、碰撞、绘制四个核心环节的 MFC 写法。
3.1 定时器驱动:MFC 里的游戏循环
在 OnInitDialog 里调用 SetTimer,启动一个周期触发 WM_TIMER 消息的定时器,就是 MFC 版本的游戏循环。
#define GAME_TIMER_ID 1 BOOL CPVsZ_MFCDlg::OnInitDialog() { CDialogEx::OnInitDialog(); // 游戏区域宽度与高度 m_nGameWidth = 800; m_nGameHeight = 600; // 33ms 一个周期,约 30 FPS SetTimer(GAME_TIMER_ID, 33, nullptr); // 初始化植物网格、僵尸列表、阳光值等数据 ResetGameState(); return TRUE; }两个参数需要解释。第一个 GAME_TIMER_ID 是定时器标识,同一窗口可以开多个定时器,靠这个 ID 区分;如果开了多个,OnTimer 的 nIDEvent 参数就是它。第二个 33 是间隔毫秒。实战中是 30 还是 50,取决于游戏物体的移动速度。物体移动算法通常是“速度 × 帧间隔毫秒 / 1000”,帧率越稳定,运动越平滑,所以定时器间隔一旦定下来尽量不要再改。
WM_TIMER 的精度在 Windows 里不算高,系统繁忙时可能合并或延迟定时器消息,这意味着 33ms 的定时器实际触发间隔可能在 30 到 50ms 之间波动。对塔防游戏这种节奏偏慢的玩法,这个精度完全够用;如果需要稳定 60 FPS,要么用更底层的多媒体定时器,要么直接上 DirectX,MFC 自身不擅长这类高频场景。
OnTimer 里做的工作只有两件事:更新游戏状态、触发重绘。
void CPVsZ_MFCDlg::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent == GAME_TIMER_ID) { UpdateGameState(); // 更新所有实体位置和状态 Invalidate(FALSE); // 请求重绘,但不擦除背景 } CDialogEx::OnTimer(nIDEvent); }UpdateGameState 内部按顺序做:阳光值自然增长、生成新僵尸、更新每个僵尸坐标、移动每颗子弹、检测碰撞并结算伤害、清理阵亡单位。顺序会影响一帧内的结果一致性,比如先移动再碰撞跟先碰撞再移动,手感差别很大。我习惯固定顺序:生成 → 移动 → 碰撞 → 清理 → 绘制,保证每帧状态是稳定的。
3.2 双缓冲绘图:解决屏闪的完整方案
MFC 的对话框默认在 OnPaint 里画图。直接画一定会闪,因为每画一个物体就清一次屏,屏幕刷新跟不上绘图节奏。双缓冲的思路:先在内存里建一块兼容位图,把所有物体画到位图上,最后一次性 BitBlt 到窗口。
void CPVsZ_MFCDlg::OnPaint() { CPaintDC dc(this); // 窗口设备上下文 // 1. 创建内存 DC 和兼容位图 CDC memDC; memDC.CreateCompatibleDC(&dc); CBitmap memBitmap; memBitmap.CreateCompatibleBitmap(&dc, m_nGameWidth, m_nGameHeight); CBitmap* pOldBitmap = memDC.SelectObject(&memBitmap); // 2. 在内存画布上绘图 CBrush groundBrush(RGB(80, 160, 60)); memDC.FillRect(CRect(0, 0, m_nGameWidth, m_nGameHeight), &groundBrush); DrawPlants(&memDC); DrawZombies(&memDC); DrawBullets(&memDC); // 3. 整块上屏 dc.BitBlt(0, 0, m_nGameWidth, m_nGameHeight, &memDC, 0, 0, SRCCOPY); // 4. 清理资源 memDC.SelectObject(pOldBitmap); memBitmap.DeleteObject(); }关键点在第三和第四步。BitBlt 的 SRCCOPY 表示直接把源内容覆盖到目标区域;如果你想做透明混合,需要改 TransparentBlt 或 AlphaBlend。第四步的清理不是可选项。GDI 对象是系统资源,虽然进程退出时系统会回收,但游戏运行中每帧泄漏一个位图,半小时后界面就会卡成 PPT。
还要堵住一个容易被忽略的口子——OnEraseBkgnd。不重写这个函数,Windows 会在 OnPaint 之前用窗口类的默认画刷把客户区擦成白色或灰色,再让 OnPaint 画新内容,擦和画交替出现,人眼看到的就是闪烁。双缓冲只解决了绘制过程中的闪烁,擦背景这一步要单独处理:
BOOL CPVsZ_MFCDlg::OnEraseBkgnd(CDC* /*pDC*/) { return TRUE; // 不擦背景,交给双缓冲的 OnPaint 全量重绘 }这个问题的经典程度,用“知道就一分钟,不知道就查一晚上”来形容毫不夸张。我第一次做 MFC 塔防时就是漏了这一步,窗口每秒闪得跟霓虹灯一样,当时还以为是双缓冲没生效,折腾了好几个晚上。
3.3 种植逻辑:像素坐标到网格坐标的映射
塔防的第一步,是处理鼠标点击并映射到网格。常规做法是 5 行 9 列,格子尺寸在初始化时算出。当用户左键点击,先换算行列,再判断是否可以种植物。
void CPVsZ_MFCDlg::OnLButtonDown(UINT nFlags, CPoint point) { // 将客户区坐标换算成格子行列 int row = (point.y - m_nGridOffsetY) / m_nGridHeight; int col = (point.x - m_nGridOffsetX) / m_nGridWidth; // 边界检查:点击到网格外的部分直接忽略 if (row < 0 || row >= 5 || col < 0 || col >= 9) { return; } // 格子已被占用 if (m_plantGrid[row][col] != nullptr) { return; } // 阳光不够种植物 if (m_nSunshine < SUNFLOWER_COST) { return; } // 创建向日葵并扣除阳光 m_plantGrid[row][col] = new Sunflower(row, col); m_nSunshine -= SUNFLOWER_COST; // 更新界面 Invalidate(FALSE); CDialogEx::OnLButtonDown(nFlags, point); }这段代码里三个提前 return 的价值在于:把“不能种”的三种情况前置,函数主线保持平铺直叙。如果你把它们写成嵌套 if,容易在 else 分支漏掉前面的条件。m_plantGrid 是一个二维指针数组,CPlant* m_plantGrid[5][9],初始全部置空;释放时机在析构函数或重置游戏时统一 delete。
坐标映射公式还有个细节:point.y - m_nGridOffsetY 得到的是相对游戏区域左上角的偏移,再除以格子高度取整。如果游戏区域左上角恰好是 (0,0),offset 就是 0;但如果界面顶部还有阳光值显示栏、植物选择栏,offset 就是这段 UI 的高度。这个偏移量忘了加,点击会被整体上移错位,后文避坑章节会再展开讲。
3.4 碰撞检测与子弹生命周期管理
塔防游戏对碰撞精度要求不高,AABB 包围盒足够。判定太严格子弹会“穿模”,太宽松又会“隔空命中”。我习惯用格子宽度的 60%、高度的 80% 作为盒子的宽和高:
bool IsHit(float bulletX, float bulletY, float zombieX, float zombieY, float gridW, float gridH) { return abs(bulletX - zombieX) < gridW * 0.3f && abs(bulletY - zombieY) < gridH * 0.4f; }0.3 和 0.4 对应盒子半宽半高,手感不好时先调这两个系数,而不是去改子弹速度。子弹用 std::vector 管理,每帧更新位置,超出边界或命中敌人就删除。
for (auto it = m_bullets.begin(); it != m_bullets.end();) { it->x += BULLET_SPEED; if (it->x > m_nGameWidth) { it = m_bullets.erase(it); // 飞出右边界,回收 continue; } bool hit = false; for (auto& zombie : m_zombies) { if (IsHit(it->x, it->y, zombie.x, zombie.y, m_nGridWidth, m_nGridHeight)) { zombie.hp -= BULLET_DAMAGE; hit = true; break; } } if (hit) { it = m_bullets.erase(it); } else { ++it; } }用 erase 的返回值更新迭代器是标准做法。如果先 it++ 再 erase,迭代器已经失效了,后面再操作就是未定义行为,调试时非常难定位。另一个性能点是 erase 本身是 O(n) 的,因为 vector 要把后续元素往前搬。元素数量几百以内不用在意,但如果僵尸数量膨胀到几千,就该考虑 swap-and-pop 把删除降成 O(1),或者改用 std::list。
到这一步,游戏逻辑的四个核心环节已经有了可落地的 MFC 实现。接下来解决更现实的问题:怎么让这个工程在自己的机器上编译运行。
4. 从 .sln 到 F5:Visual Studio 编译运行全程记录
编译 MFC 工程的难度远低于从零搭建 Win32 项目,但环境问题也能卡住不少人。这一章从环境安装开始,逐个过一遍实际会遇到的配置点。不同 VS 版本的菜单名称略有差异,原理一样。
4.1 环境要求:MFC 不是 VS 默认组件
MFC 类库不会随“使用 C++ 的桌面开发”工作负载自动安装。VS 安装器里必须额外勾选“适用于最新 v143 生成工具的 C++ MFC”,版本号因 VS 版本而异。缺失时症状是:打开工程文件时报 MFC 库找不到,或者编译时疯狂报“无法打开包括文件 afxwin.h”。解决办法不是在代码里乱试 include 路径,而是回到安装器补装组件。
工程使用 .sln 格式,需要用 Visual Studio 打开,而不是双击某个 .exe。首次打开大概率会遇到平台工具集或 Windows SDK 版本不匹配的提示。如果本机工具集更新,VS 会询问是否重定向工程;如果没弹窗但编译报 MSB8020 错误,手动在项目属性 → 常规 → 平台工具集里选择当前可用的版本。
4.2 三个关键项目属性:字符集、运行库、MFC 使用方式
项目属性是决定 MFC 工程能否“一键 F5”的三驾马车。
字符集。项目属性 → 常规 → 字符集,通常有两个选项:“使用 Unicode 字符集”和“使用多字节字符集”。保持原工程默认即可,不要随便切换。从多字节切到 Unicode,所有 TCHAR 宏和字符串字面量都会变宽字符,需要逐个处理 LPSTR/LPCSTR 与 LPWSTR/LPCWSTR 的转换,编译错误会刷满整个输出窗口,反过来切也一样。如果你的代码里没用到字符串处理,切字符集也许能过编译,但 MFC 内部的窗口类名、注册表读写都和字符集相关,不建议在这上面赌运气。
运行库。项目属性 → C/C++ → 代码生成 → 运行库,可选 /MD、/MDd、/MT、/MTd。Debug 默认 /MDd,Release 默认 /MD,一般不用改。如果目标机器没有安装 Visual C++ Redistributable 运行库,程序启动会弹“缺少 VCRUNTIME140.dll”;这时把运行库改成 /MT 或 /MTd 静态链接,生成的可执行文件自带运行库,拷贝到干净机器上也能跑,代价是 exe 体积变大。
MFC 使用方式。项目属性 → 常规 → MFC 的使用,三个选项:“不使用 MFC”“在共享 DLL 中使用 MFC”“在静态库中使用 MFC”。教学工程一般用静态库,分发时不带 MFC 的运行库 DLL。但要注意,静态链接 MFC 后编译时间和 exe 体积都会上升,这是取舍不是玄学。
4.3 编译与运行环节的排错路径
编译通过、运行出问题,按出现频率排,一般是这几类。
启动直接崩溃或闪退。多半在 InitInstance 或 OnInitDialog 里抛了异常。用调试器跑一遍,看调用栈停在哪个函数;如果停在新对象的构造函数,检查该类型的成员变量有没有初始化。Debug 模式下 VS 会给出异常类型,把详细信息贴出来搜索往往能找到答案。
窗口弹出来但一片空白。对话框框架正常,但 OnPaint 没画或画出来的位置不对。先在 OnPaint 里设置断点,运行后看是否停住;如果没停,说明消息映射表里没有 ON_WM_PAINT 条目,加上即可。如果停了但画面空白,检查画背景的 FillRect 用的矩形尺寸是否和客户区一致。
界面能显示但点击没反应。检查消息映射表里的 ON_WM_LBUTTONDOWN 条目,再检查 OnLButtonDown 函数签名是否为 UINT nFlags, CPoint point 这种标准形式。MFC 对消息处理函数签名很挑剔,参数不匹配虽然能编译通过,但运行时会收不到消息。这种情况我见过几次,参数类型写错导致点击无效,排查时一度以为是控件被遮挡了。
4.4 Debug 与 Release 的差异:初始化是关键
同一个工程 Debug 版正常、Release 版画面花掉或者随机崩溃,这是 C++ 的经典现象,MFC 也不能幸免。原因在于 Debug 下编译器会在未初始化变量区域填入固定值 0xCC,掩盖了使用未初始化变量的错误;Release 下这部分内存里是之前遗留的任意数据,行为就看运气了。
我一开始写 MFC 时就吃过这个亏。写了一个局部变量数组,用之前忘了清零循环里的边界索引,Debug 版稳如老狗,Release 版跑到第三关必崩。后来排查出是数组越界读到了别的数据,根源就是变量没有初始化。从那以后我养成了固定习惯:所有局部变量在声明时用花括号初始化,int x = {}; 指针统一置 nullptr; 结构体成员在构造函数里赋值。这套习惯大幅减少了 Release 独有的 bug。
另外,Release 下 Debug 用的 ASSERT 断言会被编译掉,如果代码依赖 ASSERT 做检查,那部分逻辑在 Release 里完全缺失。这也是需要提前设计的,不能只靠调试器兜底。
5. 避坑指南:MFC 游戏开发里的五个翻车现场
这一章整理的是实际开发里踩过、也看别人踩过的坑。每一条按“现象 → 原因 → 解决”记录。如果卡住,先来这里找对应现象。
5.1 窗口假死:定时器处理函数不允许阻塞
现象:游戏启动后,点击按钮没有任何反应,窗口也无法拖动,任务管理器显示 CPU 占用率接近 100%。
原因:在 WM_TIMER 处理函数里用了 Sleep 或忙等循环,导致消息处理线程被占住无法返回。MFC 的消息处理是串行的:OnTimer 不返回,WM_PAINT、WM_LBUTTONDOWN、WM_CLOSE 全部排队等待,界面立刻假死。
解决:删除一切阻塞调用。需要延迟时用“时间戳 + 差量更新”替代:记录上次更新时间,每次 OnTimer 触发时用 GetTickCount64() 计算差值,差值再去推进游戏逻辑。例如阳光生成可以写成 if (now - lastSunTime > 5000) { SpawnSun(); lastSunTime = now; },不睡眠也达到了延迟效果。我最初的版本就是图省事在定时器里 Sleep(500),结果窗口直接拖不动,这个教训印象极深。
5.2 构造函数里拿不到控件:初始化时机后移
现象:程序在启动阶段崩溃,调试器定位到 GetDlgItem() 调用,返回值是 nullptr,后续对返回指针解引用直接访问非法内存。
原因:构造函数的执行时机早于对话框模板加载。在对话框类构造函数里调用 GetDlgItem,此时的窗口句柄 m_hWnd 还没建立,自然拿不到任何控件。
解决:把所有依赖控件和窗口的初始化逻辑移到 OnInitDialog 中执行。构造函数只做纯数据成员初始化,任何涉及 UI 的代码都推迟到 OnInitDialog。如果你需要根据控件尺寸计算某个成员,也必须在 OnInitDialog 里算,而不是在构造函数里假设默认尺寸。
5.3 双缓冲后仍然闪烁:漏掉 WM_ERASEBKGND
现象:OnPaint 里已经创建了内存 DC 和兼容位图,画面依然在闪,尤其窗口尺寸变化时闪烁更明显。
原因:WM_ERASEBKGND 消息没有处理。默认情况下,系统会在 WM_PAINT 之前用窗口类的背景画刷擦除客户区,擦除动作和绘制动作交替进行,人眼就看到了闪烁。双缓冲只解决“一次绘制内部的闪烁”,不解决“擦除与绘制之间的闪烁”。
解决:在对话框类中添加 OnEraseBkgnd 重写,直接返回 TRUE,跳过默认背景擦除。消息映射表里加上 ON_WM_ERASEBKGND()。改动只有几行,但视觉改善立竿见影。这个坑我在本文 3.2 里已经提过,它在实际项目中出现的频率实在太高,值得在避坑清单里再占一个位置。
5.4 最小化恢复后图像丢失:资源生命周期管理失误
现象:游戏进行中,窗口最小化再恢复,植物僵尸全部消失,只剩背景色;有时要等下一次定时器触发才慢慢恢复。
原因:窗口最小化时系统可能丢弃客户区内容,恢复后由 OnPaint 全量重绘。如果绘制所需的位图对象已经在代码某处被释放,重绘时就没有素材可用。这类问题在 MFC 里尤其隐蔽,因为 GDI 对象删除后并不会立刻报错,而是过一段时间才触发随机崩溃。
解决:持有图形资源的成员变量生命周期与对话框一致,不要在局部函数里创建后再删除。如果用了 CImage::Load 或 LoadBitmap,资源加载集中在 OnInitDialog 完成,释放统一放在析构函数。养成这个习惯,可以避免大量“时好时坏”的诡异现象。
5.5 点击种植错位:坐标基准没统一
现象:点击某个格子,植物种到了相邻或更远的格子里,偏移量随点击位置变化,越靠右下方偏移越明显。
原因:坐标基准不一致。鼠标消息的 point 可能是客户区坐标,但如果某处混用了屏幕坐标,或者窗口带边框和标题栏时没有做转换,换算结果就会整体偏移。高 DPI 缩放下,系统缩放比例还会叠加更多偏移。这个坑在 150% 缩放的笔记本上尤其明显,代码在 100% 缩放的台式机上正常,到高分屏笔记本就错位。
解决:网格映射前统一坐标系。在 OnLButtonDown 里先用 ScreenToClient 把鼠标坐标转成客户区坐标,再做行列换算:
CPoint clientPt; ::ScreenToClient(m_hWnd, &clientPt); // 再用 clientPt 做网格映射,而不是直接用 point同时把项目属性的 DPI 感知设为“系统 DPI 感知”,避免系统自动拉伸客户区。如果还不行,在映射前打印 point 和客户区尺寸对比,很快能定位偏移来源。
6. 把教学 demo 改造成可扩展框架:四个落点
如果只想让这个项目跑起来看效果,到第五章就可以结束了。但如果打算在此基础上加新植物、新僵尸、新玩法,直接改对话框类会把 PVsZ_MFCDlg 越写越臃肿。我拿到这类工程会做四步改造,一次性把骨架搭好,后续加东西只在对应模块里改,不动主循环。
第一,拆管理器。按职责把逻辑拆成三个类:PlantManager 管理植物容器和种植逻辑,ZombieManager 管理僵尸容器的生成、移动和血量,BulletManager 管理子弹更新与碰撞。对话框类只保留消息分发和统一调用三个管理器的接口。拆完立竿见影的变化是:加一个植物种类只需要在 PlantManager 内部加分支,不需要动对话框代码。
第二,引入统一实体接口。定义 GameEntity 基类,声明 Update(float deltaTime) 和 Draw(CDC* pDC) 两个纯虚函数,植物、僵尸、子弹都继承它。对话框的 OnTimer 和 OnPaint 里,只需要遍历一张实体列表,分别调 Update 和 Draw。新增实体类型就是多写一个子类,主循环一行不用改。这是典型的面向对象多态落法,成本很低。
第三,资源管理集中化。把散落在各处的 LoadBitmap、LoadImage 调用收拢到一个 ResourceManager 单例里,通过资源 ID 统一加载和缓存。这个改造解决的不是功能问题,而是资源生命周期问题。所有位图的创建、缓存、释放都集中在一个地方,避免出现 5.4 那种“窗口恢复后图像消失”的隐性 bug。
第四,数值配置外置。把植物价格、攻击力、攻击间隔、子弹速度、僵尸血量、移动速度这些数值从代码里抽出来,放一个简单的 CSV 或 INI 配置,启动时读入。这样一来,调整游戏平衡性变成改配置文件,不需要重新编译。对塔防这种数值驱动明显的玩法,这一步是体验质变的来源。
四步改造做完,这个工程的原始代码已经被消化得差不多了。我拿到的第一个 MFC 小游戏就吃了只跑不改的亏:跑通一遍就把源码丢到一边,几天后再打开完全想不起结构。从那以后,我每次拿到教学 demo 都先做一遍重构,再谈加功能。同样的坑,希望你一步迈过去——这个 MFC 版植物大战僵尸工程,先跑顺,再拆透,最后改出自己的版本,希望这些经验能帮到你。
本文还有配套的精品资源,点击获取