拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C++ MFC 实战:植物大战僵尸游戏开发与避坑指南

C++ MFC 实战:植物大战僵尸游戏开发与避坑指南 简介这是一份基于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 里加一个调试文本显示当前僵尸数、子弹数、阳光数跑几分钟看数字是否稳定。如果持续上涨说明有对象没被清理。这个习惯帮我省了很多后悔药。希望帮到你。本文还有配套的精品资源点击获取
返回列表