简介这是一份面向C初学者与Windows桌面开发爱好者的MFC实战学习项目以经典塔防游戏《植物大战僵尸》为原型帮助读者理解消息驱动框架、面向对象设计与游戏基础逻辑的实现方式。资源包共15个文件约71KB以h头文件与cpp源文件为主配合vcxproj工程文件、sln解决方案、rc资源脚本及ico图标构成一套可直接在Visual Studio中打开的完整MFC工程骨架。项目围绕界面布局、事件响应、植物种植与僵尸移动等核心模块展开代码结构清晰适合作为课程设计或自学练手案例。目前已有102人学习下载。通过研读这套源码读者可以掌握MFC对话框程序的组织方式、资源加载与消息映射机制并在此基础上尝试扩展关卡、优化双缓冲绘制是理解C游戏开发入门路径的一份轻量参考。1. 用 C 和 MFC 复刻植物大战僵尸2025 年还值得动手吗2025 年再拿 MFC 写植物大战僵尸很多人第一反应是「这玩意儿还有人用」。但如果你正在学 C、想找一个能把面向对象、消息循环、GDI 绘图、定时器、资源管理全部串起来的练手项目MFC 版植物大战僵尸反而是性价比极高的选择。它不像纯控制台小游戏那样只练语法也不像 UE 那样一上来就被引擎抽象层淹没——你需要自己管理窗口、自己画精灵、自己算碰撞、自己调度波次每一行代码都能看到 Win32 消息泵在背后转。这个标题对应的不是某个具体仓库而是一类经典实现路径用 Visual Studio 的 MFC 框架搭一个单文档或对话框程序把草坪、阳光、豌豆、僵尸全部用 GDI/GDI 绘制出来用定时器驱动帧循环用鼠标消息处理种植交互。适合已经会 C 基础语法、想通过一个完整项目理解 Windows 桌面程序结构的开发者也适合想给教学演示找一个有视觉反馈案例的人。下面按「先跑通最小可玩版本再补机制最后调手感」的顺序拆开讲。2. 环境搭建与最小可玩窗口从空工程到第一株豌豆2.1 为什么选 MFC 而不是 Qt 或纯 Win32选型这件事在 2025 年确实值得说清楚。Qt 跨平台、信号槽优雅但如果你目标就是 Windows 桌面、想贴着 Win32 API 理解消息机制MFC 的封装厚度刚好——它没有把WndProc藏得太深你仍然能看到ON_WM_PAINT()、ON_WM_TIMER()这些宏背后对应的消息映射。纯 Win32 又太薄写一个按钮都要几十行做游戏时大量时间耗在样板代码上。MFC 的CDC、CBitmap、CRect这些类把 GDI 资源包了一层用起来比裸 GDI 顺手同时又不至于像游戏引擎那样把渲染循环完全接管。另一个现实原因是 Visual Studio 对 MFC 的支持一直没断社区里 MFC 教程和状态栏、对话框传值这类具体问题的讨论仍然活跃遇到问题搜得到答案。植物大战僵尸这种 2D 塔防逻辑复杂度适中正好卡在「能练到东西」和「不会烂尾」之间。2.2 创建工程与配置 GDI 的完整步骤打开 Visual Studio新建项目选「MFC 应用」应用程序类型选「单文档」项目名比如PvZMFC。单文档自带CView派生类把游戏绘制放在OnDraw里最自然。创建完成后先做一件事在stdafx.h或pch.h里引入 GDI 头文件并链接库。// pch.h 末尾添加 #include gdiplus.h #pragma comment(lib, gdiplus.lib) using namespace Gdiplus;然后在CWinApp派生类的InitInstance里初始化 GDI在ExitInstance里关闭。// PvZMFCApp.h 中添加成员 ULONG_PTR m_gdiplusToken; // PvZMFCApp.cpp InitInstance 中在创建主窗口之前 GdiplusStartupInput gdiplusStartupInput; GdiplusStartup(m_gdiplusToken, gdiplusStartupInput, NULL); // ExitInstance 中 GdiplusShutdown(m_gdiplusToken);逻辑说明GDI 比传统 GDI 多了 Alpha 通道混合和抗锯齿植物僵尸的 PNG 素材带透明背景用 GDI 的Graphics::DrawImage能直接画出透明效果省去手动做掩码图的麻烦。参数上GdiplusStartupInput用默认构造即可不需要改。注意初始化必须在任何 GDI 对象创建之前关闭必须在所有 GDI 对象析构之后否则会出现资源泄漏或绘制异常。2.3 用定时器驱动帧循环并画出第一块草坪MFC 里做游戏循环常见做法是用SetTimer设一个 16 毫秒或 33 毫秒的定时器在OnTimer里更新逻辑并调用Invalidate触发重绘。16 毫秒对应约 60 帧33 毫秒约 30 帧对植物大战僵尸这种节奏足够。// 在 View 类的 OnInitialUpdate 中 SetTimer(1, 33, NULL); // OnTimer 处理 void CPvZMFCView::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent 1) { UpdateGame(); // 更新阳光、僵尸位置、子弹碰撞 Invalidate(FALSE); // 触发重绘FALSE 表示不擦背景减少闪烁 } CView::OnTimer(nIDEvent); }Invalidate(FALSE)的参数很关键传TRUE会先用背景色擦除整个客户区再重绘画面会闪传FALSE不擦背景配合双缓冲就能得到稳定画面。双缓冲的做法是在OnDraw里先画到内存 DC再一次性BitBlt到屏幕 DC。void CPvZMFCView::OnDraw(CDC* pDC) { CRect rect; GetClientRect(rect); CDC memDC; memDC.CreateCompatibleDC(pDC); CBitmap memBmp; memBmp.CreateCompatibleBitmap(pDC, rect.Width(), rect.Height()); memDC.SelectObject(memBmp); // 用 GDI 在 memDC 上绘制 Graphics graphics(memDC.GetSafeHdc()); graphics.DrawImage(m_lawnBg, 0, 0, rect.Width(), rect.Height()); pDC-BitBlt(0, 0, rect.Width(), rect.Height(), memDC, 0, 0, SRCCOPY); }参数说明CreateCompatibleBitmap的尺寸必须和客户区一致否则拉伸或裁剪BitBlt最后一个参数SRCCOPY表示直接拷贝。内存 DC 和位图每次重绘都创建销毁开销不小实际项目里应该做成成员变量只创建一次这里为了讲清流程先写在一起。3. 核心机制实现阳光、种植、僵尸波次与碰撞3.1 用结构体数组管理游戏对象植物大战僵尸里的对象类型不多阳光、植物、僵尸、子弹。每种用一个结构体或类表示用一个std::vector存所有实例。不要用链表遍历和随机访问都更慢vector的连续内存对缓存友好。struct Plant { int type; // 0 向日葵 1 豌豆射手 2 坚果墙 int row, col; // 格子坐标 int hp; int shootTimer; // 射击冷却计数 bool alive; }; struct Zombie { float x, y; // 像素坐标x 用 float 便于平滑移动 int row; int hp; float speed; // 像素/帧 bool alive; }; std::vectorPlant m_plants; std::vectorZombie m_zombies; std::vectorSun m_suns; std::vectorBullet m_bullets;逻辑说明僵尸的x用float而不是int因为每帧移动速度可能是 0.5 像素用整数会累积误差导致移动不均匀。alive标记配合每帧末尾的remove_if清理死亡对象避免遍历时删除导致迭代器失效。m_zombies.erase( std::remove_if(m_zombies.begin(), m_zombies.end(), [](const Zombie z) { return !z.alive; }), m_zombies.end());3.2 阳光生成与收集的定时逻辑阳光分两种天上随机掉落和向日葵产出。天上掉落用一个独立计时器每隔 5 到 10 秒随机生成一个从屏幕顶部往下飘。向日葵产出则每个向日葵自己维护一个冷却计数每 24 秒左右产一个。// 每帧更新阳光 void UpdateSuns() { static int skyTimer 0; skyTimer; if (skyTimer 300) { // 300 帧约 10 秒 skyTimer 0; Sun s; s.x (float)(rand() % (LAWN_WIDTH - 60) 30); s.y -60.0f; s.targetY (float)(rand() % 400 100); s.value 25; s.alive true; m_suns.push_back(s); } for (auto s : m_suns) { if (s.y s.targetY) s.y 1.0f; } }参数说明skyTimer 300在 30 帧每秒下约 10 秒想更频繁就调小这个值。targetY让阳光停在不同高度避免全部堆在底部。收集判定在鼠标点击消息里做遍历所有阳光判断点击坐标是否在阳光矩形内是则加阳光数并标记alive false。3.3 僵尸波次调度与碰撞检测僵尸波次不能一上来就全放出来要有节奏。常见做法是维护一个波次计数器和一个总计时器每过一定时间放一波每波僵尸数量和血量随波次递增。void SpawnZombies() { static int waveTimer 0; static int waveCount 0; waveTimer; if (waveTimer 600) { // 20 秒一波 waveTimer 0; waveCount; int count 1 waveCount / 2; for (int i 0; i count; i) { Zombie z; z.row rand() % 5; z.x (float)LAWN_WIDTH i * 80.0f; z.y LAWN_TOP z.row * CELL_HEIGHT; z.hp 100 waveCount * 20; z.speed 0.3f waveCount * 0.02f; z.alive true; m_zombies.push_back(z); } } }碰撞检测分两类子弹和僵尸、僵尸和植物。子弹和僵尸用矩形相交判断僵尸和植物用格子坐标判断——僵尸走到某个格子且该格有植物就停下啃。bool IsCollide(const CRect a, const CRect b) { return a.left b.right a.right b.left a.top b.bottom a.bottom b.top; }注意僵尸的碰撞矩形要比视觉图像窄一点否则僵尸还没碰到植物就开始啃玩家会觉得判定「玄学」。这是血泪经验视觉和判定分离判定框通常比图像小 10% 到 20%。4. 避坑与排查MFC 做游戏最容易翻车的五个地方4.1 画面闪烁严重僵尸像在抽搐现象每次重绘整个客户区闪白快速移动的僵尸出现拖影或抖动。原因OnDraw里直接往屏幕 DC 画且Invalidate传了TRUE导致背景擦除和绘制不同步。解决改用内存 DC 双缓冲Invalidate(FALSE)并在OnEraseBkgnd里直接返回TRUE阻止背景擦除。BOOL CPvZMFCView::OnEraseBkgnd(CDC* pDC) { return TRUE; // 告诉框架背景已处理不要再擦 }4.2 定时器消息堆积导致逻辑跳帧现象游戏运行一段时间后突然加速僵尸瞬移。原因OnTimer里做了耗时操作比如加载图片消息队列里积压了多个WM_TIMER系统会合并但逻辑上仍可能一次处理多帧。解决定时器回调里只做轻量更新图片资源在OnInitialUpdate里预加载如果单帧逻辑耗时超过定时器间隔改用QueryPerformanceCounter自己做时间差控制。4.3 GDI 对象泄漏程序越跑越卡现象运行几分钟后画面卡顿任务管理器里 GDI 对象数持续上涨。原因每次OnDraw都CreateCompatibleDC和CreateCompatibleBitmap但没有DeleteDC和DeleteObject。解决把内存 DC 和位图做成成员变量在OnSize里重建在析构里释放。用任务管理器的「GDI 对象」列可以直观看到泄漏。4.4 鼠标点击坐标和绘制坐标对不上现象点击草坪明明点在格子上植物却种到旁边。原因OnLButtonDown拿到的坐标是客户区坐标但如果窗口有滚动条或工具栏客户区原点和绘制原点不一致。解决统一用GetClientRect得到的客户区坐标做所有计算绘制时也以客户区左上角为原点。如果用了CScrollView需要调用GetScrollPosition做偏移补偿。4.5 僵尸走到最左边没有判负游戏卡死现象僵尸走到房子位置后继续往左走消失不见游戏不结束。原因只写了僵尸移动逻辑没写到达终点的判负条件。解决每帧检查僵尸x是否小于某个阈值是则触发游戏结束状态停止定时器弹出结束对话框或绘制结束画面。for (auto z : m_zombies) { if (z.x 0 z.alive) { KillTimer(1); m_gameOver true; Invalidate(FALSE); break; } }5. 进阶技巧让复刻版手感接近原版的三个调参5.1 用帧间插值让僵尸移动更平滑30 帧下僵尸每帧移动 0.3 像素肉眼能看出台阶感。一个简单技巧是记录上一帧位置绘制时在上一帧和当前帧之间做线性插值用Graphics::DrawImage的目标矩形做亚像素偏移。更彻底的做法是把逻辑更新和渲染解耦逻辑固定 60 次每秒渲染按实际帧率插值。MFC 里可以用QueryPerformanceCounter算 deltaTime把僵尸移动改成x - speed * deltaTime这样不同性能的机器上速度一致。5.2 阳光收集的点击热区要比图像大原版里点阳光很跟手因为热区比视觉图像大一圈。实现时把阳光的碰撞矩形向外扩 10 像素玩家不用精确点到图像边缘。这个改动很小但手感提升明显属于「后悔药」级别的调整——上线前加比上线后加省事得多。CRect sunRect(s.x - 10, s.y - 10, s.x 70, s.y 70); if (sunRect.PtInRect(point)) { /* 收集 */ }5.3 用状态机管理植物行为而不是一堆 if豌豆射手的逻辑是「有僵尸在同行且在前方就射击否则待机」向日葵是「冷却好了就产阳光」。如果全用if-else堆在Update里加一种植物就要改一大片。用简单状态机每个植物有state字段Update里switch(state)状态切换条件独立成函数。这样加新植物只需加一个 case 和对应状态转移不改动已有逻辑。enum PlantState { IDLE, SHOOTING, COOLDOWN }; switch (plant.state) { case IDLE: if (HasZombieInRow(plant.row, plant.col)) plant.state SHOOTING; break; case SHOOTING: if (plant.shootTimer 0) { FireBullet(plant); plant.shootTimer 30; } plant.state COOLDOWN; break; case COOLDOWN: if (--plant.shootTimer 0) plant.state IDLE; break; }我自己的习惯是每加一个新机制先问「这个逻辑能不能塞进现有状态机」能塞就不加新分支。植物大战僵尸的机制扩展性很好阳光、植物、僵尸、子弹四类对象各自独立更新耦合点只在碰撞和格子占用上。把格子占用做成一个二维数组m_grid[row][col]存植物索引僵尸走到格子时查这个数组决定是否啃比遍历所有植物快得多。这个项目写到后面你会发现最花时间的不是写逻辑而是调数值——僵尸速度、阳光掉落频率、植物冷却这些参数直接决定好不好玩。建议把所有这些数值集中到一个头文件里用const或宏定义改起来不用满工程搜。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?