news 2026/10/7 1:13:05

2025年用C++和MFC复刻植物大战僵尸:从零搭建到避坑指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
2025年用C++和MFC复刻植物大战僵尸:从零搭建到避坑指南

简介:这是一份面向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); // ID=1, 间隔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::vector<Sun> 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。参数vy=1.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的间隔不要小于 10ms,Windows 定时器精度有限,16ms 是稳妥值。

4.4 鼠标点击坐标偏移,点阳光没反应

现象:明明点在阳光上,却没收集到。原因:OnLButtonDown拿到的point是客户区坐标,但如果窗口有工具栏、状态栏,或者用了ScreenToClient转换错误,就会偏。解决:统一用GetClientRect的坐标系,所有游戏对象坐标都基于客户区左上角。调试时在点击处画个红点,肉眼确认坐标对不对。

4.5 资源图片加载失败,画出来是黑块

现象:植物僵尸全是黑色方块。原因:CImage或CBitmap加载路径不对,或者图片格式不支持。MFC 原生CBitmap只支持 BMP,PNG 要用 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++ 前缀和」在算法题里是求区间和,在这里是缩小检测范围,思路相通。

最后说个习惯:我每次改完碰撞逻辑,都会把子弹和僵尸的包围盒画出来(红色矩形),肉眼确认判定范围合不合理,确认完再注释掉。这个「可视化调试」的习惯帮我省了无数排查时间。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/7 1:12:56

Java web学生选课系统课程设计:源码、数据库与报告完整拆解

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/7 1:12:43

CSP-J复试全拆解:四题考点、工程避坑与分数最大化

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/7 1:12:29

故障录波器实战解析:从启动原理到典型波形分析方法

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/7 1:11:48

YOLOv8教室人数统计实战:从数据标注到部署的完整指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/7 1:10:57

SCR ESD器件回滞特性原理与Vh工程设计全解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/7 1:10:57

2021年中国自然保护区矢量面数据:GIS叠加分析与分区统计实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华