
简介这是一份面向C初学者与Windows桌面开发爱好者的MFC实战学习项目以经典塔防游戏《植物大战僵尸》为原型帮助读者理解消息驱动框架、面向对象设计与游戏基础逻辑的实现方式。压缩包共15个文件约71KB包含6个h头文件、3个cpp源文件以及vcxproj工程文件、sln解决方案、rc与rc2资源脚本、filters筛选器和ico图标等覆盖工程配置、界面资源与核心代码模块结构完整可直接用Visual Studio打开编译。项目围绕游戏界面布局、事件响应、植物种植与僵尸移动攻击逻辑、资源加载释放及双缓冲优化等环节展开适合作为课程设计或自学练手案例。目前已有102人学习下载读者可借此掌握MFC工程组织方式、消息映射机制与C类设计思路为后续转向更专业的游戏引擎打下基础。1. 用 C 和 MFC 复刻植物大战僵尸2025 年还值得动手吗2025 年再翻出 MFC 写一个植物大战僵尸很多人第一反应是「这玩意儿还有人用」。但如果你正在学 C、想找一个能同时练到窗口消息、GDI 绘图、定时器、资源管理和面向对象的项目MFC 版植物大战僵尸反而是性价比很高的练手靶子。它不像纯控制台小游戏那样只练语法也不像引擎项目那样一上来就被渲染管线劝退。你要处理的是真实问题阳光怎么定时掉落、僵尸怎么沿格子寻路、豌豆怎么判定命中、卡片冷却怎么刷新、窗口重绘怎么不闪。这些恰好是 C 小游戏里最典型的工程问题。这篇文章面向两类人刚学完 C 基础、想找一个完整项目把语法用起来的新手以及写过 MFC 但只会对话框、想搞清楚 GDI 双缓冲和游戏循环的老手。下面按「先搭骨架、再填玩法、最后调手感」的顺序讲代码可以直接在 VS 里跑。2. 环境搭建与 MFC 工程骨架从空对话框到能跑的游戏窗口2.1 为什么选 MFC 而不是 Qt 或 EasyX先说选型。2025 年做 C 小游戏常见路线有三条EasyX、Qt、MFC。EasyX 上手最快但封装太厚你学不到窗口消息循环Qt 跨平台、控件丰富但信号槽机制会掩盖 Win32 消息的本质MFC 是 Win32 的薄封装OnPaint、OnTimer、OnLButtonDown这些消息处理函数直接对应底层WM_PAINT、WM_TIMER、WM_LBUTTONDOWN。用 MFC 写植物大战僵尸你被迫理解「消息驱动 定时器驱动」的游戏循环模型这个模型在嵌入式 HMI、工业上位机里同样通用。热词里有人问「mfc 状态栏怎么显示」其实游戏里阳光数、波次进度就是靠状态栏或自绘区域显示的原理一样。所以选 MFC 不是为了怀旧是为了把 Win32 消息机制吃透。2.2 创建工程与关键配置打开 Visual Studio 2022新建项目选「MFC 应用」名称填PVZ_MFC应用程序类型选「单文档」项目样式选「MFC 标准」。生成后先别急着写逻辑改三个地方。第一在stdafx.h或pch.h里确认包含 GDI 头文件后面画半透明阳光要用// pch.h 末尾追加 #include gdiplus.h #pragma comment(lib, gdiplus.lib) using namespace Gdiplus;第二在App类的InitInstance里初始化 GDI退出时释放// PVZ_MFCApp::InitInstance 中 GdiplusStartupInput gdiplusStartupInput; ULONG_PTR gdiplusToken; GdiplusStartup(gdiplusToken, gdiplusStartupInput, NULL); // ... 原有代码 ... // 在 ExitInstance 中 GdiplusShutdown(gdiplusToken);第三把窗口客户区固定成 900x600避免拉伸导致坐标错乱。在MainFrm.cpp的PreCreateWindow里改BOOL CMainFrame::PreCreateWindow(CREATESTRUCT cs) { cs.cx 900; // 客户区宽 cs.cy 600; // 客户区高 cs.style ~FWS_ADDTOTITLE; // 去掉标题栏自动追加文档名 return CFrameWnd::PreCreateWindow(cs); }参数说明cs.cx/cy是窗口尺寸实际客户区会略小后面用GetClientRect动态取。FWS_ADDTOTITLE去掉后标题干净。这三步做完编译运行应该看到一个空白窗口骨架就成了。2.3 游戏循环定时器还是线程MFC 里做游戏循环常见做法是SetTimer每 16ms 触发一次OnTimer在里头更新逻辑再Invalidate触发重绘。16ms 对应约 60 帧够用。不要用while(true)死循环会把消息泵堵死窗口直接假死。定时器写法// 在 View 类的构造函数或 OnCreate 中 SetTimer(1, 16, NULL); // ID1, 间隔16ms void CPVZ_MFCView::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent 1) { UpdateGame(); // 更新阳光、僵尸、子弹位置 Invalidate(FALSE); // FALSE 表示不擦背景减少闪烁 } CView::OnTimer(nIDEvent); }Invalidate(FALSE)是关键参数FALSE表示不发送WM_ERASEBKGND配合后面的双缓冲能基本消除闪烁。如果发现画面还是抖就在OnEraseBkgnd里直接return TRUE把背景擦除交给双缓冲位图处理。3. 核心玩法实现阳光、植物、僵尸与子弹的 GDI 绘制3.1 双缓冲绘图解决闪烁的必经之路MFC 默认绘图是直接往屏幕 DC 上画每帧先擦背景再画前景中间会闪。标准解法是内存 DC 双缓冲先把整帧画到内存位图再一次性BitBlt到屏幕。这是 MFC 游戏最核心的一步代码必须背下来void CPVZ_MFCView::OnDraw(CDC* pDC) { CRect rc; GetClientRect(rc); // 1. 创建内存 DC 和兼容位图 CDC memDC; memDC.CreateCompatibleDC(pDC); CBitmap bmp; bmp.CreateCompatibleBitmap(pDC, rc.Width(), rc.Height()); CBitmap* pOldBmp memDC.SelectObject(bmp); // 2. 在内存 DC 上画背景和所有游戏对象 memDC.FillSolidRect(rc, RGB(120, 180, 90)); // 草地绿 DrawGrid(memDC); // 画 5x9 格子 DrawPlants(memDC); // 画植物 DrawZombies(memDC); // 画僵尸 DrawBullets(memDC); // 画豌豆 DrawSun(memDC); // 画阳光 DrawUI(memDC); // 画卡片栏和阳光数 // 3. 一次性贴到屏幕 pDC-BitBlt(0, 0, rc.Width(), rc.Height(), memDC, 0, 0, SRCCOPY); // 4. 清理 memDC.SelectObject(pOldBmp); }逻辑说明CreateCompatibleDC创建的内存 DC 与屏幕格式兼容CreateCompatibleBitmap创建同尺寸位图。所有绘制在内存完成最后BitBlt一次拷贝人眼看不到中间过程。参数SRCCOPY表示直接复制源像素。注意位图要在函数结束时选回旧对象否则 GDI 对象泄漏跑几分钟就会卡死。3.2 阳光掉落与收集定时器 命中检测阳光是游戏的经济系统。常见做法是每 5 秒生成一个阳光从屏幕顶部随机 x 坐标落下落到一定 y 后停住10 秒不点就消失。用结构体管理struct Sun { float x, y; float vy; // 下落速度 bool active; DWORD bornTime; // 生成时间用于超时消失 }; std::vectorSun g_suns; void UpdateSuns() { DWORD now GetTickCount(); for (auto s : g_suns) { if (!s.active) continue; if (s.y 500) s.y s.vy; // 未落地继续落 if (now - s.bornTime 10000) // 10秒超时 s.active false; } // 每5秒生成一个 static DWORD lastGen 0; if (now - lastGen 5000) { Sun s; s.x 100 rand() % 700; s.y -30; s.vy 1.5f; s.active true; s.bornTime now; g_suns.push_back(s); lastGen now; } }点击收集在OnLButtonDown里做圆形命中检测鼠标点到阳光中心距离小于 25 像素就算收集阳光数加 25。参数vy1.5是下落速度调大阳光掉得快调小更悠闲。10000是存活毫秒数想更宽松就改 15000。这里用GetTickCount而不是time因为前者毫秒级适合游戏计时。3.3 植物放置与卡片冷却植物卡片栏放在顶部每张卡片有冷却时间。用结构体记录卡片状态struct PlantCard { int type; // 0向日葵 1豌豆 2坚果 int cost; // 阳光消耗 DWORD lastUse; // 上次使用时间 DWORD cooldown; // 冷却毫秒 }; PlantCard g_cards[3] { {0, 50, 0, 5000}, {1, 100, 0, 5000}, {2, 50, 0, 30000} };点击卡片选中再点格子放置。放置前检查三件事阳光够不够、冷却好没好、格子空不空。冷却进度用GetTickCount() - lastUse和cooldown比较绘制时画一个从下往上的灰色遮罩表示冷却。这个「选中态 目标格」的交互模式在 MFC 里就是两个成员变量m_selectedCard和m_hoverCell鼠标移动时更新m_hoverCell绘制时高亮。3.4 僵尸寻路与子弹碰撞僵尸从右侧出现沿所在行向左走。每行一条独立路径所以僵尸结构体里存row和x。移动就是每帧x - speed。当僵尸 x 坐标与某植物格子重叠进入啃食状态停止移动并扣植物血量。子弹碰撞用矩形相交判断bool IsHit(const Bullet b, const Zombie z) { CRect rb((int)b.x, (int)b.y, (int)b.x 20, (int)b.y 20); CRect rz((int)z.x, (int)z.y, (int)z.x 60, (int)z.y 90); return rb.IntersectRect(rb, rz); }IntersectRect是 MFC 自带的矩形相交函数返回非零表示相交。子弹命中后僵尸扣血、子弹置为失效。这里有个血泪经验子弹速度别设太快否则一帧跨过僵尸导致穿透常见做法是子弹每帧移动不超过僵尸宽度的一半或者做射线检测。新手直接限制speed 10就够。4. 避坑与排查MFC 游戏开发最容易翻车的 5 个地方4.1 画面闪烁严重像老式日光灯现象窗口一刷新就白一下僵尸移动时拖影。原因默认OnEraseBkgnd会先用背景色刷一遍再画前景两次绘制之间被看到。解决在 View 类里重写OnEraseBkgnd直接返回 TRUE并在OnDraw里用双缓冲一次性贴图。如果还闪检查Invalidate是不是传了 TRUE改成Invalidate(FALSE)。4.2 GDI 对象泄漏跑几分钟就卡死现象游戏运行越久越卡最后无响应。原因每次OnDraw都CreateCompatibleDC和CreateCompatibleBitmap但没有DeleteDC和DeleteObject。GDI 对象有数量上限默认 10000超了就崩。解决内存 DC 和位图用完必须释放或者做成成员变量只创建一次窗口大小变化时再重建。用任务管理器看 GDI 对象数正常应该稳定不涨。4.3 定时器消息堆积逻辑更新忽快忽慢现象僵尸移动速度不稳定有时一顿一顿。原因OnTimer里做了耗时操作比如加载图片导致消息队列堆积定时器消息被合并。解决图片资源在OnCreate里一次性加载到内存OnTimer只做纯计算和坐标更新。另外SetTimer的间隔不要小于 10msWindows 定时器精度有限16ms 是稳妥值。4.4 鼠标点击坐标偏移点阳光没反应现象明明点在阳光上却没收集到。原因OnLButtonDown拿到的point是客户区坐标但如果窗口有工具栏、状态栏或者用了ScreenToClient转换错误就会偏。解决统一用GetClientRect的坐标系所有游戏对象坐标都基于客户区左上角。调试时在点击处画个红点肉眼确认坐标对不对。4.5 资源图片加载失败画出来是黑块现象植物僵尸全是黑色方块。原因CImage或CBitmap加载路径不对或者图片格式不支持。MFC 原生CBitmap只支持 BMPPNG 要用 GDI 的Image类。解决用 GDI 加载 PNG路径用绝对路径或相对于 exe 的路径调试时打印GetLastError。另外注意透明通道GDI 的DrawImage配合ImageAttributes才能正确显示 PNG 透明区域。5. 进阶技巧让复刻版手感接近原版的 3 个细节5.1 用状态机管理僵尸行为而不是一堆 if新手写僵尸容易写成if (啃食) {...} else if (行走) {...}状态一多就乱。常见做法是给僵尸加一个enum State { WALK, EAT, DIE }每个状态一个更新函数切换时做一次性的初始化。这样加「冰冻」「减速」状态时只需新增分支不用改原有逻辑。状态机是游戏 AI 的基本功MFC 里用switch实现就够不必上框架。5.2 帧率无关的移动用 deltaTime 而不是固定步长前面用固定 16ms 定时器如果系统卡顿僵尸会瞬移。更稳的做法是记录两次更新的时间差deltaTime移动量 速度 × deltaTime。这样无论帧率怎么变僵尸每秒走过的像素数恒定。代码DWORD now GetTickCount(); float dt (now - m_lastTick) / 1000.0f; // 转秒 m_lastTick now; zombie.x - zombie.speed * dt; // speed 单位像素/秒参数speed改成 30 表示每秒走 30 像素比每帧走 2 像素更直观。注意dt要 clamp 到 0.1 秒以内防止调试断点后僵尸瞬移。5.3 用前缀和思想优化碰撞检测当僵尸和子弹数量上百时两两检测是 O(n²)会卡。常见优化是按行分组子弹只和同一行的僵尸检测因为豌豆不会跨行。这样复杂度降到 O(n×m)m 是每行僵尸数。再进一步每行僵尸按 x 排序子弹只检查 x 范围内最近的几个。这就是前缀和/滑动窗口在游戏里的朴素应用热词里「c 前缀和」在算法题里是求区间和在这里是缩小检测范围思路相通。最后说个习惯我每次改完碰撞逻辑都会把子弹和僵尸的包围盒画出来红色矩形肉眼确认判定范围合不合理确认完再注释掉。这个「可视化调试」的习惯帮我省了无数排查时间。希望帮到你。本文还有配套的精品资源点击获取