☰
C++ MFC 实战:植物大战僵尸游戏开发与避坑指南
2026/9/28 6:26:20 网站建设 项目流程

简介:这是一份基于C++与MFC框架实现的《植物大战僵尸》游戏源码项目,面向具备一定C++基础、希望理解Windows桌面程序结构与游戏开发基本概念的初学者与进阶学习者。项目围绕MFC消息驱动机制展开,涵盖界面布局、事件响应、植物种植与僵尸移动攻击等核心逻辑,并涉及资源加载释放与双缓冲等性能优化思路,可作为学习Windows程序架构与面向对象设计的实践案例。压缩包共15个文件,约71KB,包含6个h头文件、3个cpp源文件,以及vcxproj工程文件、sln解决方案、rc与rc2资源脚本、filters筛选器和ico图标等,结构完整,可直接在Visual Studio中打开编译。目前已有102人学习下载。通过阅读与调试这套代码,读者能够掌握MFC对话框程序的组织方式、消息映射的编写方法,并理解游戏循环、对象管理与资源调度在C++中的落地实现,为后续深入学习DirectX或专业游戏引擎打下基础。

1. C++ 植物大战僵尸 MFC 版:为什么 2025 年还有人拿它练手

2025 年还在用 C++ 和 MFC 写植物大战僵尸,听起来像是老古董翻新,但真上手做过一轮的人会明白:这是一个把 C++ 基础、Windows 消息机制、GDI 绘图、定时器调度、资源管理全串起来的练手项目。它不像 Unity 那样把渲染、碰撞、帧循环都封装好,你得自己算格子坐标、自己贴图、自己处理鼠标点击落在哪一格。做完之后,你对 C++ 游戏循环和 MFC 四大类(CWinApp、CFrameWnd、CView、CDocument)的理解会比看十篇教程都扎实。适合谁?适合已经会写 C++ 控制台程序、想找一个有画面、有交互、有状态管理的项目来突破“只会写黑框框”阶段的人。也适合想搞懂 MFC 消息映射和 GDI 双缓冲的开发者——植物大战僵尸的草坪格子、阳光掉落、僵尸行走,天然就是消息驱动和定时刷新的好素材。

2. 先把架子搭稳:MFC 单文档框架与游戏主循环

2.1 为什么选单文档而不是对话框

常见做法有两种:基于对话框的 MFC 程序,或者单文档(SDI)。对话框程序上手快,拖控件就能跑,但植物大战僵尸需要一块连续刷新、响应鼠标、自己绘制的画布,对话框的控件体系反而碍事。单文档的 CView 派生类天生就是一块可绘制的窗口客户区,OnDraw 里拿 CDC 指针直接画,鼠标消息、定时器消息也都落在 View 上,职责清晰。

我一般会这样建工程:Visual Studio 里新建 MFC 应用,选“单个文档”,项目名比如 PvZMFC。生成后你会得到四个核心类:CWinApp 派生类管启动,CFrameWnd 派生类管主窗口框架,CDocument 派生类管数据(这里可以放游戏状态),CView 派生类管显示。游戏逻辑主要塞在 View 和 Document 里,Frame 基本不动。

提示:如果 Visual Studio 安装时没勾 MFC 组件,新建项目里找不到 MFC 模板。去 Visual Studio Installer 里勾“使用 C++ 的桌面开发”下的 MFC 支持,离线环境需要提前下好对应组件包。

2.2 用定时器驱动帧刷新

MFC 没有内置游戏循环,最直接的办法是 SetTimer。在 View 的 OnCreate 里起一个定时器,间隔决定刷新频率:

// PvZView.h 中声明 UINT_PTR m_nTimerID; // PvZView.cpp int CPvZView::OnCreate(LPCREATESTRUCT lpCreateStruct) { if (CView::OnCreate(lpCreateStruct) == -1) return -1; // 约 60 FPS,间隔 16ms m_nTimerID = SetTimer(1, 16, nullptr); if (m_nTimerID == 0) { AfxMessageBox(_T("定时器创建失败")); return -1; } return 0; } void CPvZView::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent == m_nTimerID) { // 更新游戏逻辑:阳光增长、僵尸移动、子弹飞行 UpdateGame(); // 触发重绘 Invalidate(FALSE); } CView::OnTimer(nIDEvent); } void CPvZView::OnDestroy() { if (m_nTimerID != 0) { KillTimer(m_nTimerID); m_nTimerID = 0; } CView::OnDestroy(); }

逻辑说明:SetTimer 的第二个参数是毫秒间隔,16ms 对应约 60 帧每秒。OnTimer 里先跑 UpdateGame 改状态,再 Invalidate 请求重绘,重绘最终走到 OnDraw。参数上要注意,间隔不是越小越好,Windows 定时器精度有限,设成 1ms 也不会真的 1ms 触发,反而增加消息队列压力。16ms 到 33ms 是常见区间。KillTimer 必须在窗口销毁前调用,否则定时器消息可能打到已经析构的对象上,这是血泪经验。

2.3 双缓冲解决画面闪烁

直接在 OnDraw 里往屏幕 CDC 上画,僵尸一动就闪。标准做法是内存 DC 双缓冲:

void CPvZView::OnDraw(CDC* pDC) { CPvZDoc* pDoc = GetDocument(); CRect rcClient; GetClientRect(&rcClient); // 内存 DC CDC memDC; memDC.CreateCompatibleDC(pDC); // 内存位图 CBitmap memBmp; memBmp.CreateCompatibleBitmap(pDC, rcClient.Width(), rcClient.Height()); CBitmap* pOldBmp = memDC.SelectObject(&memBmp); // 先画背景 memDC.FillSolidRect(&rcClient, RGB(60, 120, 60)); // 画草坪格子、植物、僵尸、子弹 DrawLawn(&memDC); DrawPlants(&memDC); DrawZombies(&memDC); DrawBullets(&memDC); // 一次性贴到屏幕 pDC->BitBlt(0, 0, rcClient.Width(), rcClient.Height(), &memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBmp); }

逻辑说明:所有绘制先在内存 DC 上完成,最后用一次 BitBlt 整体拷贝到屏幕,屏幕只刷新一次,闪烁消失。参数上,CreateCompatibleBitmap 用的是屏幕 DC 的格式,保证颜色一致。SelectObject 之后一定要恢复旧对象,否则 GDI 对象泄漏,程序跑久了会卡死。这个坑我踩过不止一次。

3. 草坪、阳光、僵尸:核心玩法模块怎么落地

3.1 格子坐标与鼠标点击映射

植物大战僵尸的草坪是 5 行 9 列。每个格子有固定宽高,比如 80x100 像素。鼠标点击时要把屏幕坐标转成行列号:

// 常量定义 const int ROWS = 5; const int COLS = 9; const int CELL_W = 80; const int CELL_H = 100; const int LAWN_LEFT = 250; // 草坪左上角 x const int LAWN_TOP = 100; // 草坪左上角 y void CPvZView::OnLButtonDown(UINT nFlags, CPoint point) { // 判断是否点在草坪范围内 int col = (point.x - LAWN_LEFT) / CELL_W; int row = (point.y - LAWN_TOP) / CELL_H; if (row >= 0 && row < ROWS && col >= 0 && col < COLS) { // 检查该格是否已有植物 if (!m_lawn[row][col].HasPlant()) { // 检查阳光是否够 if (m_sunCount >= PLANT_COST) { m_lawn[row][col].Plant(PLANT_PEASHOOTER); m_sunCount -= PLANT_COST; } } } CView::OnLButtonDown(nFlags, point); }

逻辑说明:先减偏移再整除,得到行列索引。边界判断不能少,否则点到草坪外面会越界。参数上,LAWN_LEFT 和 LAWN_TOP 要根据你实际背景图的位置调,不同素材包对不齐是常见翻车点。PLANT_COST 是阳光消耗,豌豆射手一般设 100。

3.2 阳光自动掉落与收集

阳光有两种来源:天上掉和向日葵产。天上掉的阳光可以用一个结构体管理:

struct SunDrop { int x, y; // 当前位置 int targetY; // 目标落点 bool collected; // 是否被收集 int life; // 存活时间 }; // 在 UpdateGame 里更新 void CPvZView::UpdateGame() { // 每隔一定帧数生成新阳光 static int frameCount = 0; frameCount++; if (frameCount % 300 == 0) // 约 5 秒一个 { SunDrop s; s.x = LAWN_LEFT + rand() % (COLS * CELL_W); s.y = 0; s.targetY = LAWN_TOP + rand() % (ROWS * CELL_H); s.collected = false; s.life = 600; // 约 10 秒后消失 m_suns.push_back(s); } // 更新已有阳光 for (auto& s : m_suns) { if (!s.collected && s.y < s.targetY) s.y += 2; // 下落速度 s.life--; } // 移除过期或已收集的 m_suns.erase( std::remove_if(m_suns.begin(), m_suns.end(), [](const SunDrop& s) { return s.collected || s.life <= 0; }), m_suns.end()); }

逻辑说明:用 vector 存所有阳光,每帧更新位置和寿命。rand() 生成随机横坐标,让阳光落点有变化。参数上,下落速度 2 像素每帧,寿命 600 帧约 10 秒,这些都可以按手感调。注意 rand() 需要 srand 初始化种子,否则每次运行序列一样。

3.3 僵尸移动与碰撞检测

僵尸从右侧出现,向左走。每帧更新 x 坐标,当僵尸 x 小于某列植物的 x 范围时判定碰撞:

struct Zombie { int x, y; int row; int hp; int speed; bool eating; }; void CPvZView::UpdateZombies() { for (auto& z : m_zombies) { if (z.eating) { // 正在吃植物,不移动 continue; } z.x -= z.speed; // 检查是否碰到植物 int col = (z.x - LAWN_LEFT) / CELL_W; if (col >= 0 && col < COLS && m_lawn[z.row][col].HasPlant()) { z.eating = true; z.x = LAWN_LEFT + col * CELL_W + CELL_W; // 停在植物右侧 } // 到达最左侧,游戏失败 if (z.x < LAWN_LEFT) { m_gameOver = true; } } }

逻辑说明:僵尸每帧左移 speed 像素,碰到植物就切换 eating 状态停止移动。参数上 speed 一般设 1 到 2,太快游戏没法玩。碰撞检测用的是格子索引,比像素级碰撞简单可靠。注意僵尸的 y 坐标由 row 决定,不要单独存,否则容易和格子对不上。

4. 避坑与排查:MFC 做游戏最容易翻车的几个地方

4.1 定时器消息打到已销毁对象

现象:关闭窗口时程序崩溃,或者调试输出里出现访问违规。原因:KillTimer 没在 OnDestroy 里调用,定时器还在往已经析构的 View 发消息。解决:OnDestroy 里先 KillTimer,再调基类。如果用了多个定时器,每个都要杀干净。

4.2 GDI 对象泄漏导致越跑越卡

现象:游戏运行几分钟后画面卡顿,任务管理器里 GDI 对象数持续上涨。原因:OnDraw 里创建的 CDC、CBitmap、CFont 等没有恢复或释放。解决:SelectObject 之后必须 SelectObject 回旧对象,CreateCompatibleDC 和 CreateCompatibleBitmap 创建的临时对象在函数结束时会析构,但选入 DC 的对象不会自动释放。养成“谁创建谁释放,谁选入谁恢复”的习惯。

4.3 图片资源加载失败但没报错

现象:植物和僵尸画不出来,背景一片纯色。原因:LoadBitmap 或从文件加载图片时路径不对,或者资源 ID 写错,但返回值没检查。解决:每次加载资源后判断句柄是否为空,为空就 AfxMessageBox 提示。资源路径用相对路径时注意工作目录,调试时工作目录可能是项目目录而不是 exe 目录。

4.4 鼠标点击坐标偏移

现象:点格子种植物,种到了隔壁格。原因:背景图有边框或者草坪起始坐标没算对。解决:把 LAWN_LEFT 和 LAWN_TOP 做成可调参数,运行时用鼠标点击打印坐标来校准。也可以画一个调试用的格子线框,确认对齐后再去掉。

4.5 随机数每次运行都一样

现象:阳光落点、僵尸出现顺序每局完全相同。原因:rand() 没播种,默认种子是 1。解决:在程序启动时调用 srand((unsigned)time(nullptr)),需要包含 。注意 srand 只调一次,不要每帧调。

5. 进阶技巧:用状态机和对象池把代码理清楚

写到后面你会发现,植物、僵尸、子弹、阳光各自有状态,用一堆 if-else 会越来越乱。我一般会引入一个简单的状态机。比如僵尸有行走、吃植物、死亡三个状态,用枚举加 switch 处理:

enum class ZombieState { Walking, Eating, Dying }; void CPvZView::UpdateZombie(Zombie& z) { switch (z.state) { case ZombieState::Walking: z.x -= z.speed; if (HitPlant(z)) z.state = ZombieState::Eating; break; case ZombieState::Eating: z.eatTimer++; if (z.eatTimer >= EAT_DURATION) { RemovePlantAt(z.row, z.x); z.state = ZombieState::Walking; z.eatTimer = 0; } break; case ZombieState::Dying: z.deathTimer++; if (z.deathTimer >= DEATH_ANIM_FRAMES) z.markForDelete = true; break; } }

逻辑说明:每个状态只做自己该做的事,状态切换条件清晰。参数上 EAT_DURATION 控制吃植物的速度,DEATH_ANIM_FRAMES 控制死亡动画时长。这样加新僵尸类型时只需要改状态逻辑,不用动主循环。

另一个技巧是对象池。子弹和阳光频繁创建销毁,用 vector 加标记删除比每次 new/delete 更稳。每帧遍历时跳过 markForDelete 的对象,帧末统一清理。这样避免了在遍历中删除元素导致的迭代器失效,也是我踩过坑之后固定下来的写法。

验证方法很简单:在 OnDraw 里加一个调试文本,显示当前僵尸数、子弹数、阳光数,跑几分钟看数字是否稳定。如果持续上涨,说明有对象没被清理。这个习惯帮我省了很多后悔药。

希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询