news 2026/9/24 19:05:06

用MFC从零实现植物大战僵尸:类设计、双缓冲绘制与碰撞检测

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
用MFC从零实现植物大战僵尸:类设计、双缓冲绘制与碰撞检测

简介:面向C++与Windows桌面开发初学者的一份植物大战僵尸MFC实现源码,以Visual Studio工程形式提供。项目将塔防玩法的核心框架迁移到MFC对话框程序中,从代码结构可看到游戏界面搭建、控件消息处理、植物与僵尸对象的基础交互逻辑,并涉及资源加载与简单动画调度,适合想了解MFC游戏开发流程、巩固面向对象编程的读者,也可作为课程设计或毕业设计的参考原型。压缩包共15个文件,包含6个.h头文件与3个.cpp源文件,另有.sln工程解决方案、.vcxproj项目配置、.rc资源脚本、.ico图标等,完整构成一个可编译的MFC应用程序;包体仅71KB,结构精简,便于逐文件理解MFC程序的模块划分。目前已有102人学习,如果你已经掌握基本C++语法、希望跨入Windows图形界面编程,这份代码能直观展示MFC消息驱动模型与UI协作方式,缩短从控制台到桌面应用的距离。

1. 植物大战僵尸的 MFC 版:从控制台字符画到窗口程序的关键一跃

很多人搜“C++ 小游戏”时,看到最多的植物大战僵尸教程都是控制台版:字符方块当草坪,字母 Z 当僵尸,向日葵就是一行笑脸符号。能编译能运行,却离“像样的窗口程序”差着一个 MFC。MFC 版把游戏真正搬进 Windows 窗口:鼠标点草坪种植物、定时器驱动僵尸刷新、GDI 画出草坪和弹道。做完你会有种很实在的感觉——原来类设计、消息循环、图形绘制这些语法书上的名词,真的能拼出一个能玩的游戏。它适合刚学完 C++、正在找课程设计方向的人,也适合想复盘 Win32 程序结构的熟手。

2. 为什么 2025 年还选 MFC 写小游戏:类架构与 Doc/View 的落地取舍

选 MFC 做植物大战僵尸,在 2025 年看像在翻老黄历,实际上是一条最稳妥的路。MFC 对 Win32 封装得不算厚,你能直接感受到窗口怎么创建、消息怎么流转;编译出来单个 exe,桌面环境基本开箱即跑。这一章先把“为什么选它”和“类怎么拆”讲清楚,后面动手才不慌。

2.1 三选一:为什么不是控制台、不是 Qt,而是 MFC

控制台版本我也写过。植物大战僵尸这种游戏,核心乐趣在“看僵尸从右侧走过来、植物朝它开火”的实时画面,控制台只能用字符帧去模拟,每帧重画整个终端,闪烁和刷新频率都是硬伤。网上不少“用 C++ 制作僵尸末日小游戏”的教程最后都停在字符拼图上,因为控制台根本没给你窗口程序的能力。真想做窗口程序,就得进入 Win32 的世界,而 MFC 是把 Win32 包得最薄、最能看清底层的 C++ 类库。

Qt 当然更现代,信号槽、QML、跨平台都很好,但对这个项目来说有个现实问题:发布时要带一堆 DLL,工程结构也比 MFC 重。课程设计或毕业设计答辩时,MFC 的消息映射、文档视图分离,能清清楚楚讲出“框架在做什么、我写的代码在哪一层”,这种教学价值是 Qt 给不了的。MFC 类库本身也不算厚,它把 CreateWindow、WndProc、消息分发这些脏活接过去,但你写的代码仍然能直通 Win32 API,出了问题知道往哪查。

还有一个 2025 年的现实:Microsoft Visual C++ Redistributable 基本是 Windows 装机标配,MFC 编译出来的程序拿到别的机器上,跑不起来的概率比 Qt 程序低得多。体积上,一个 Release 版 exe 也就在几 MB 到十几 MB 的量级,加上素材目录就能拷走。对单人小游戏来说,这比带 Qt 运行库的发布包省心太多。

提示:新建 MFC 工程时有个常见陷阱——对话框模板如果选了“在静态库中使用 MFC”,第一次生成工程容易遇到资源句柄找不到、对话框创建失败的报错。建议先用“共享 DLL 中使用 MFC”跑通全流程,最后想减小依赖再改静态库,能少踩一个坑。

2.2 拆出六个核心类:对象模型决定游戏能走多远

动手写代码之前,先把对象模型定下来。植物大战僵尸的核心实体就四类:植物、僵尸、子弹、阳光,再加上一张草坪地图和一个管理逻辑的控制器。我会把每个实体做成一个类,全部继承自同一个基类,这样后续的绘制和碰撞遍历就能用多态统一处理。这是整个工程最关键的一步,对象拆不好,后面写几百行到处是 if 分支,维护起来想摔键盘。

下面是一个简化版的类设计骨架,直接抄进工程就能用:

// GameObjects.h —— 植物大战僵尸核心对象模型(简化版) enum class ePlantType { Sunflower = 0, Peashooter, Wallnut, CherryBomb }; class CGameObject { // 所有游戏对象的基类 public: virtual ~CGameObject() = default; virtual void Update() = 0; // 每帧逻辑更新 virtual void Draw(CDC* pDC) = 0; // 每帧绘制 int m_row = 0, m_col = 0; // 所在格子(子弹存当前行) int m_hp = 100; bool m_dead = false; }; class CPlant : public CGameObject { public: ePlantType m_type; int m_produceTimer = 0; // 生产阳光/豌豆的计时器 }; class CZombie : public CGameObject { public: int m_speed = 1; // 每帧左移像素 int m_attackTimer = 0; // 攻击冷却 }; class CBullet : public CGameObject { public: int m_speed = 5; // 每帧右移像素 int m_damage = 20; }; class CSun : public CGameObject { public: int m_fallSpeed = 1; // 下落速度 };

逻辑说明:基类把“每帧做什么”抽象成 Update 和 Draw 两个纯虚函数,派生类各自实现。m_row 和 m_col 记录对象所在网格位置,碰撞检测时“先比行号、再比矩形”全靠这两个字段做粗筛。m_dead 不是直接删对象,而是打个标记,由每帧结束后的统一清理逻辑移除,避免在遍历容器时删除元素导致的迭代器失效。

参数说明:注意基类析构函数必须写成 virtual,否则用基类指针 delete 派生类对象时不会调用派生类析构函数,这是一个典型的内存安全漏洞。ePlantType 用 enum class 而不是普通 enum,可以避免枚举常量泄漏到外层作用域。m_produceTimer 和 m_attackTimer 是每帧递减的计数器,配合固定帧率实现“向日葵每 5 秒产一次阳光”这类节奏控制。

2.3 数据放文档、绘制放视图:Doc/View 架构里的一亩三分地

MFC 单文档(SDI)工程会自动生成四个类:CWinApp 子类、CMainFrame、CGameDoc 和 CGameView。很多初学者喜欢把游戏数据直接塞在 View 里,因为写起来顺手。但我建议数据一律放进 Document 那边,View 只负责两件事:接收鼠标键盘消息,以及把自己拿到的数据画出来。这样做的理由是:OnDraw 可能因为窗口遮挡、拖动、最小化还原被系统随时触发,在绘制路径里改游戏数据,本质上是在一个不确定的时机写共享状态,很容易养出随机 bug。

我一般的做法是单独写一个 CGameLogic 类,持有所有对象容器和地图数据,然后让 CGameDoc 持有 CGameLogic 的实例。CGameView 通过 GetDocument() 拿到文档指针,进而访问 CGameLogic。这比把 CGameLogic 直接塞进 View 多绕了一层,但好处很实在:将来想加存档、读档功能,直接在 Document 层做序列化,不用碰任何绘制代码。

// GameDoc.h —— 文档持有游戏逻辑,视图只负责取数绘制 class CGameDoc : public CDocument { public: CGameLogic m_logic; // 游戏逻辑全部放在这里 };

很多人从控制台转 MFC 时最不适应的一点,就是“控制台里 printf 随口就来,窗口里往哪打日志”。MFC 的对应做法是 TRACE 宏或 OutputDebugString,输出到 Visual Studio 的“调试输出”窗口。这种“兼容控制台和窗口能力”的过渡期大概会持续一两天,扛过去之后,你会在 OnDraw 里通过断点清晰地看到每一帧的画面是怎么拼出来的。

3. 双缓冲与透明贴图:让游戏画面不闪屏的 GDI 绘制管线

MFC 默认的绘制方式存在一个原罪:屏幕每刷新一次,窗口会被擦成灰色,然后重新画上内容,擦和画之间有几十毫秒的间隔,人眼看到的就是疯狂闪烁。植物大战僵尸这种画面里几十个对象、每帧全量重绘的游戏,不用双缓冲根本没法看。这一章把绘制管线和素材显示一次讲透。

3.1 绘制顺序为什么不能乱:后画的永远盖住先画的

GDI 的绘图像往墙上贴海报,后贴的盖住先贴的。这个简单规则决定了整帧画面的遮挡关系。植物大战僵尸里,草坪在最底下,然后依次是阳光、植物、子弹、僵尸。为什么僵尸要放在最后画?因为僵尸从右侧进入草坪时是踩着地面走的,视觉上应该在植物前方;子弹从植物嘴部飞出,飞行路径在植物和僵尸之间,所以排在植物之后、僵尸之前。

很多新手把绘制顺序搞反,先画僵尸再画植物,结果豌豆射手把自己的子弹挡住了,僵尸走一步就被植物遮挡得时隐时现。这个顺序不是玄学,是按“谁离观众更近谁后画”的透视关系定的。每一帧 OnDraw 都要按这个顺序完整画一遍,不做局部增量,这也是双缓冲存在的原因——全量重绘不闪,靠的就是先在内存画完再一次性输出。

3.2 用 CDC 和 CBitmap 搭双缓冲:一段可以直接抄的 OnDraw

双缓冲的原理说穿了不值钱:先在一块内存 DC 上把整帧画完,画的过程中屏幕纹丝不动,画完用一次 BitBlt 把整块内存图拷到屏幕 DC 上。屏幕上的观众永远不会看到画了一半的画面,闪烁自然消失。下面是一段完整的 OnDraw 实现,也是整个游戏绘制部分的心脏。

void CGameView::OnDraw(CDC* pDC) { // 双缓冲:先在内存里画完,再一次 BitBlt 到屏幕,避免闪烁 CRect rc; GetClientRect(&rc); // 取客户区实际尺寸 CDC memDC; memDC.CreateCompatibleDC(pDC); // 创建与屏幕兼容的内存 DC CBitmap bmp; bmp.CreateCompatibleBitmap(pDC, rc.Width(), rc.Height()); CBitmap* pOldBmp = memDC.SelectObject(&bmp); // 选中位图并保存旧位图 // 按遮挡关系从底到顶绘制 m_doc->m_logic.DrawBackground(&memDC); // 1. 草坪与网格线 m_doc->m_logic.DrawSuns(&memDC); // 2. 阳光 m_doc->m_logic.DrawPlants(&memDC); // 3. 植物 m_doc->m_logic.DrawBullets(&memDC); // 4. 子弹 m_doc->m_logic.DrawZombies(&memDC); // 5. 僵尸 // 一次性拷回屏幕,完成整帧输出 pDC->BitBlt(0, 0, rc.Width(), rc.Height(), &memDC, 0, 0, SRCCOPY); // 恢复旧位图,让局部变量析构时能干净释放 GDI 对象 memDC.SelectObject(pOldBmp); }

逻辑说明:CreateCompatibleDC 创建的内存 DC 本身没有绘图表面,必须选入一张位图才能真正画上去。CreateCompatibleBitmap 以屏幕 DC 为模板创建位图,保证颜色格式一致。SelectObject 返回旧位图指针,函数结束前要把它选回去,否则 bmp 析构时 GDI 对象可能残留。所有 Draw 函数都接收 memDC,画到内存表面上,最后 BitBlt 一次输出。

参数说明:位图宽高必须用 GetClientRect 实时取的客户区尺寸,不能用固定值。如果客户区是 800x600,你用 640x480 的位图做双缓冲,BitBlt 时宽高不匹配,要么画面被裁剪,要么右侧出现未绘制的垃圾区域。SRCCOPY 光栅操作码表示整块覆盖,永远不要画背景前清屏,双缓冲就不需要单独的 FillRect。

3.3 MFC 显示 BMP 图片与 PNG 透明贴图:素材加载的现实做法

游戏要像样,就不能用 FillSolidRect 画色块,得用图片素材。CBitmap 原生只支持 BMP,但网上下载的植物僵尸素材基本全是 PNG,透明背景靠 Alpha 通道。直接用 CBitmap 加载 PNG 会失败,这是很多初学者卡住的第一道坎。2025 年的做法是用 CImage,它从 VS2008 开始就是 MFC 自带的类,既能加载 BMP 也能加载 PNG,还支持 Alpha 通道直接合成。

// 加载 PNG 素材并绘制到目标 DC(在初始化阶段完成,不要放到每帧里) CImage img; HRESULT hr = img.Load(L".\\res\\peashooter.png"); // 从磁盘加载 PNG if (FAILED(hr)) { AfxMessageBox(L"加载植物图片失败"); return; } // 绘制到内存 DC 的 (x, y) 位置,宽高按目标尺寸缩放 img.Draw(memDC.GetSafeHdc(), x, y, cellW, cellH);

逻辑说明:CImage::Load 返回 HRESULT,必须用 FAILED 宏检查。Draw 的四个参数是目标 DC 句柄和绘制矩形,内部会处理 PNG 的 Alpha 混合,透明背景直接叠加到底图上。取 DC 句柄用的是 GetSafeHdc(),如果 DC 还没创建会返回 NULL,所以务必保证传入的 CDC 已 CreateCompatibleDC。

参数说明:Draw 会对原图做缩放到 cellW 和 cellH,这是有性能成本的。正确做法是初始化时把每种植物、僵尸的素材按目标格子尺寸预先缩放成“就绪位图”存好,运行时只做贴图不做缩放。如果你手里的资源是 BMP,没有 Alpha 通道,就得准备一张黑色背景的掩码图,用 BitBlt 的 SRCAND 和 SRCPAINT 两种光栅操作各画一遍,先按位与把目标区域挖成黑色,再按位或把图案贴上去,原理相当于老式透明胶片的套准。能用 PNG 就不用这套土办法,CImage 的直接 Draw 省心太多。

4. 鼠标种豆与定时器驱动:网格映射、消息路由和碰撞检测怎么做

画面不闪了,下一步要解决交互和逻辑推进。这一章解决两个核心问题:鼠标点到哪块草坪就种哪块,以及游戏里的时间怎么往前走。这两件事做完,这个游戏才从“一幅静态的画面”变成“一个能玩的东西”。

4.1 从鼠标点击到网格坐标:ScreenToGrid 的偏移与越界检查

植物不能种在草坪外面,也不能种在已经种了东西的格子上。所以鼠标点击必须映射到 9x5 的网格坐标。OnLButtonDown 收到的是客户区坐标,而草坪在窗口里有一个左上角起点和固定的格子宽高,把点击点换算成格子编号,本质就是两步:先减掉草坪原点偏移,再整除格子尺寸。

// 屏幕坐标 -> 草坪格子坐标,草坪左上角在 (kGridLeft, kGridTop) BOOL CGameView::ScreenToGrid(CPoint pt, int& row, int& col) { const int kGridLeft = 40; // 草坪左边距 const int kGridTop = 80; // 草坪上边距 const int kCellW = 80; // 每格宽 const int kCellH = 100; // 每格高 if (pt.x < kGridLeft || pt.y < kGridTop) { return FALSE; // 点击位置在草坪左上角之外 } col = (pt.x - kGridLeft) / kCellW; row = (pt.y - kGridTop) / kCellH; if (col < 0 || col >= 9 || row < 0 || row >= 5) { return FALSE; // 越界,不允许种植 } return TRUE; }

逻辑说明:先判断点是否在草坪区域左侧或上方,避免负坐标整除出负数网格。col 和 row 是引用参数,函数返回后由调用方拿到格子位置。越界检查放在最后,9 列 5 行是草坪规格,也是游戏规则里不可突破的边界。

参数说明:kGridLeft 和 kGridTop 不是随便定的,它们来自草坪背景图里实际草坪区域的像素位置。你换了背景图,这两个值就要跟着改,建议把它们提成 CGameView 的成员变量,在 OnSize 里根据窗口尺寸重算,否则窗口最大化后点击位置就全部错位。点击事件传给 OnLButtonDown 的参数 point 本身就是客户区坐标,不要再调 ScreenToClient,那是给屏幕坐标准备的。

种下去之后,本体的逻辑只是创建一个 CPlant 对象塞进对应格子。刷僵尸和掉阳光的位置需要随机数,老工程里常看到 rand() % 5 取行号,但 rand() 的分布质量差而且每次运行序列固定。C++11 的 库里 mt19937 是成熟选择,游戏启动时用 std::random_device 做种子,刷怪行、阳光掉落 x 坐标都从同一个 mt19937 里取,分布均匀得多。

4.2 用 SetTimer 给游戏一个心跳:33 毫秒一拍的逻辑循环

植物大战僵尸是典型的策略节奏游戏,不需要 60 帧的丝滑手感,每秒 30 帧逻辑更新完全足够,也就是 33 毫秒一拍。MFC 里启动这个心跳最直接的工具就是 SetTimer,配合 WM_TIMER 消息驱动。它的好处是天然跑在主消息循环里,不涉及跨线程访问窗口句柄的问题。

int CGameView::OnCreate(LPCREATESTRUCT lpCreateStruct) { if (CView::OnCreate(lpCreateStruct) == -1) return -1; // 启动游戏心跳:每 33ms 触发一次 WM_TIMER SetTimer(kTimerGame, 33, NULL); return 0; } void CGameView::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent == kTimerGame) { m_doc->m_logic.Update(); // 1. 更新所有对象状态 m_doc->m_logic.SpawnZombieIfNeeded(); // 2. 按波次生成僵尸 Invalidate(FALSE); // 3. 触发重绘(不擦背景) } CView::OnTimer(nIDEvent); } void CGameView::OnDestroy() { KillTimer(kTimerGame); // 窗口销毁时关掉心跳 CView::OnDestroy(); }

逻辑说明:SetTimer 在 OnCreate 里启动,KillTimer 在 OnDestroy 里成对出现,漏掉后患无穷——窗口关了定时器还活着,WM_TIMER 会发给一个已销毁的窗口句柄。Update 里做所有逻辑推进:向日葵计时产阳光、豌豆射手发射子弹、子弹右移、僵尸左移、阳光下落、碰撞检测。SpawnZombieIfNeeded 内部自己维护刷怪冷却和波次计数,不会每帧都刷。

参数说明:kTimerGame 是自定义的定时器 ID,建议用枚举常量而不是魔鬼数字。33 毫秒对应约 30 FPS,对植物大战僵尸足够了;改成 50 毫秒是 20 FPS,僵尸移动会明显变“肉”。如果你把间隔改成 10 毫秒硬冲 100 FPS,MFC 的消息循环根本跑不到那么快,反而让 CPU 空转发热,没有实际收益。

这里要强调一个“C++ 多线程”的经典翻车点:不要在子线程里直接访问窗口句柄或调用 Invalidate。MFC 的窗口对象绑定主线程的消息队列,子线程碰它轻则失效重则崩溃。如果以后想用工作线程加载资源,正确姿势是 PostMessage 把结果抛回主线程处理,而不是子线程里直接画图。

4.3 碰撞检测:先比行号、再比矩形,不要一上来就上四叉树

子弹要打中僵尸,僵尸要啃掉植物,碰撞检测是游戏逻辑的核心。植物大战僵尸的规则天然给了个优化:豌豆子弹只沿当前行飞行,僵尸也只能被打同行的子弹击中。所以碰撞检测的第一步是行号粗筛,行不同直接跳过,只有行相同时才做精确的矩形相交测试。

void CGameLogic::CheckCollisions() { for (auto& bullet : m_bullets) { CRect bulletRect = bullet->GetRect(); for (auto& zombie : m_zombies) { if (bullet->m_row != zombie->m_row) continue; // 行号不同,直接跳过 CRect zombieRect = zombie->GetRect(); CRect hitRect; if (hitRect.IntersectRect(bulletRect, zombieRect)) { zombie->TakeDamage(bullet->m_damage); bullet->m_dead = true; break; // 同一颗子弹只命中一次 } } } }

逻辑说明:CRect::IntersectRect 是 MFC 封装好的矩形相交判断,两个矩形有重叠时返回 TRUE 并算出相交区域。行号粗筛把复杂度从“子弹数 × 僵尸数”降到“子弹数 × 同行僵尸数”,植物大战僵尸同屏对象一般几十个,这个优化已经绰绰有余。break 保证一颗子弹在同一帧里最多打中一个僵尸,否则子弹会穿糖葫芦一样一路穿透所有僵尸。

参数说明:GetRect 每个对象自己实现,返回当前帧的矩形位置,内部用 m_row、m_col 和格子尺寸换算成像素坐标。僵尸啃植物的检测同理:遍历植物和僵尸,IntersectRect 相交后进入攻击计时,每 0.5 秒扣一次植物血量,而不是每帧都扣。对象数量没有大到上万之前,不要引入四叉树或空间哈希,那是引擎级的需求,在这个项目里属于过度设计,只会增加调试成本。

5. MFC 游戏避坑清单:GDI 泄漏、中文路径与刷怪卡顿的五个实测教训

MFC 的坑大多藏在运行时库里:编译能过、Debug 能跑,偏偏在某个特定操作后开始翻车。这一章把我在这个方向上见过最多、踩过最深的五个坑按“现象 → 原因 → 解决”拆开写。说穿了,这些问题没有一个靠背八股能防住,全是运行几分钟到半小时才浮出来的顽疾。

5.1 玩半小时后画面花屏:GDI 对象泄漏在悄悄拖垮进程

现象:游戏刚启动一切正常,连续玩二十到三十分钟,画面开始出现怪异的黑色块、按钮文字变虚,再过几分钟整个窗口像被泼了墨。打开任务管理器切到“详细信息”,给进程加上“GDI 对象”列,数字已经飙到几千甚至上万;Developer 调试输出窗口里还频繁刷出 dumpcont.cpp 相关的 ATLTRACE 堆报告。

原因:GDI 对象是系统级资源,不随窗口销毁自动回收。最典型的写法就是在 OnDraw 里直接 new CBitmap、CreateSolidBrush,执行完又没 DeleteObject。每帧漏两个句柄,一秒钟漏几十个,系统默认一个进程的 GDI 对象上限约一万,半小时到一小时就会撞顶,画出来的东西开始莫名其妙地消失。

解决:所有画刷、字体、位图都用局部对象自动析构,或者做成成员变量在初始化时创建一次。用 SelectObject 选入临时对象后,务必把返回的旧对象选回来再让局部对象出作用域。调试期每次改完绘制代码,开着任务管理器盯 GDI 对象列,数值稳定不涨再继续往下写,这是最笨也最有效的验证手段。

5.2 素材放到中文目录就加载失败:路径问题的顽固程度超预期

现象:素材图片放在英文路径下一切正常,整个项目目录拷到桌面上的“新建文件夹”或者带中文名字的路径里,重新编译运行,植物和僵尸全部变成空白方块。更迷惑的是 Debug 输出里没有任何报错,程序继续照常跑。

原因:Load 函数返回的 HRESULT 被忽略了,代码根本没进失败分支。MFC 工程默认使用 Unicode 字符集,而字符串常量如果是窄字符的 ANSI 编码,跟宽字符路径一转码就出问题;再叠加素材路径里的中文编码与系统代码页不匹配,文件流打开失败就成了静默错误。中文路径是罪魁,但没检查返回值让这个错误藏得特别深。

解决:所有 Load 调用必须检查返回值,失败时用 GetLastError 把具体错误码输出到调试窗口。图片资源优先用资源编辑器编进 exe,从资源 ID 加载而不是从文件路径加载,彻底绕开路径问题。如果坚持用外部图片文件,用 GetModuleFileName 取到 exe 所在目录,再拼相对路径,不要依赖当前工作目录。

5.3 阳光一掉就卡顿:把磁盘操作塞进了主循环

现象:向日葵每次产出阳光、僵尸每次刷新,游戏立刻掉帧一瞬。阳光从空中下落的过程像放幻灯片,但具体卡在哪一步完全看不出来,像在操作一个黑匣子。

原因:在 OnTimer 或 Update 里直接调 CImage::Load 从磁盘加载 PNG,还要现场解码。磁盘 I/O 在小文件上看似只有几毫秒,但主循环里每帧要做几十个对象的逻辑更新和绘制,这里多出的几十毫秒直接把帧间隔打爆。游戏逻辑和资源加载混在一起,属于最典型的架构问题,不是优化问题。

解决:所有图形素材在 OnInitialUpdate 或构造函数里一次性预加载成成员变量,放到一个 CGameResourceManager 类里统一管理,运行时 OnTimer 只做坐标运算和状态更新,绘制阶段只是把现成的位图贴到对应位置。素材文件数量多的话,可以做一个静态索引表,用植物类型枚举值取对应位图,避免在游戏循环里出现任何字符串拼接和文件打开操作。

5.4 窗口拉大后出现黑边和点击错位:双缓冲位图没有跟随客户区

现象:运行起来把窗口拉大或最大化,画面右侧和底部出现一片灰色或黑色区域,鼠标点下去的种植位置和实际种下的格子错了一大截,看起来像整个草坪被“拽歪了”。

原因:双缓冲位图是在 OnCreate 或第一次 OnDraw 时按当时的客户区尺寸创建的,此后窗口尺寸变了,位图还是老的。OnDraw 照旧按老尺寸创建位图,BitBlt 时宽高参数又是用 GetClientRect 新取的,两者一错位,右边和底边就漏出了未绘制的背景区域。网格映射里的固定常量偏移也没有跟着窗口布局重算,点击错位随之而来。

解决:重写 OnSize 消息响应,在里面获取新客户区尺寸,把双缓冲位图释放后按新尺寸重新创建,同时重算草坪原点和格子尺寸。网格映射函数不要使用硬编码常量,改为读取成员变量,这些成员变量在 OnSize 里统一更新。记住一个原则:所有和窗口坐标相关的参数,生命周期都要跟客户区尺寸绑定,不能在绘制函数里写死。

5.5 Debug 和 Release 行为不一致:字符集与运行库配置的锅

现象:Debug 版本编译运行一切正常,切到 Release 版本,读取存档直接乱码,甚至有概率启动后就崩溃。两个版本用的明明是一份代码,表现却像两个程序。

原因:Debug 版默认链接调试版运行库 /MTd,Release 版链接 /MT,两者对 std::string 的内存布局、边界检查约定都不完全一致。如果你在存档文件里直接写入 std::string 的内部内存结构,跨配置读取必然翻车。再加上工程里 CString 和 std::string 混用,MFC 默认 Unicode、外部素材文件名却是 ANSI,两套编码一混叠,乱码和崩溃就一起来。

解决:所有工程配置统一用 Unicode 字符集,字符串类型要么全用 CString,要么全用 std::wstring,杜绝混用。存档文件写成可读文本格式,最省事的是简单用逗号分隔或 JSON,不要直接 dump 内存结构。跑 Release 前先全面读档一次,把 Debug 和 Release 的存档文件分开存放在不同目录里,避免互相污染。

6. 用 C++17 收尾:智能指针接管对象生命周期,顺手验证帧率与 GDI 占用

游戏主循环跑通后,还有一个容易被忽略的收尾工作:对象内存管理。MFC 老代码习惯用裸 new 和 delete,对象一多在新增和移除时就容易出错。C++17 的 smart pointer 在这里正好派上用场,这也是 2025 年写 C++ 的默认姿势。

6.1 用 std::unique_ptr 接管游戏对象容器

游戏里的植物、僵尸、子弹都在运行中不断创建和销毁,用裸指针容器管理,最怕的是某个分支忘了 delete。用 unique_ptr 之后,对象生命周期跟容器条目绑定,移除条目时自动释放内存。

#include <memory> // 对象容器:持有派生类对象的唯一所有权 std::vector<std::unique_ptr<CGameObject>> m_objects; // 创建一棵新植物 auto plant = std::make_unique<CSunflower>(row, col); m_objects.push_back(std::move(plant)); // 每帧结束统一清理死亡对象 auto it = std::remove_if(m_objects.begin(), m_objects.end(), [](const std::unique_ptr<CGameObject>& obj) { return obj->IsDead(); }); m_objects.erase(it, m_objects.end());

逻辑说明:std::make_unique 构造对象,push_back 结合 std::move 把所有权转移进容器。清理时用 remove_if 先把死亡对象移到容器尾部,再调用 erase 统一删除,这两步组合是 vector 删除元素的标准姿势。基类析构函数是 virtual 的,所以 unique_ptr 析构时会正确调用派生类析构函数,形成完整的多态删除。

参数说明:IsDead() 返回 m_dead 标记,这里用 lambda 做谓词捕获容器元素类型。如果你的 MFC 版本编译器支持 C++17(VS2019 及以后都支持),还能用 if constexpr 在模板函数里区分不同植物类型的额外逻辑,编译期就完成分支裁减。

6.2 高 DPI 屏幕下的最后一公里:PerMonitorV2 与点击错位

现代电脑很多是 4K 屏配 150% 或 200% 缩放,老 MFC 程序在这类机器上会出现两个问题:窗口整体模糊,以及鼠标点击位置和显示内容错位。原因默认 DPI 感知级别下,系统把窗口当成 96 DPI 渲染再整体拉伸,缩放比例一大,GDI 绘制的坐标和鼠标消息的物理坐标就对不上了。

解决方式:在工程里声明 PerMonitorV2 DPI 感知。Visual Studio 中通过“工程属性 → 清单工具 → 输入和输出 → DPI 感知”设置为 Per Monitor High DPI Aware。配合在 InitInstance 里调用 SetProcessDpiAwarenessContext,程序启动时告诉系统自己会按每个显示器的实际 DPI 调整布局,系统就不再做拉伸虚拟化,画面清晰了,鼠标坐标也准了。注意网格映射的常量参数这时候仍要跟着缩放比例走,我一般会在 OnSize 里读 GetDpiForWindow 并维护一个全局缩放系数。

6.3 三分钟性能验证:帧耗时、GDI 对象数与内存占用

收尾阶段最重要的不是加功能,而是验证这游戏能在低配机器上长时间挂机不崩。我的验证流程三件事:第一,在 Update 和 Draw 前后用 GetTickCount64 各打一次点,算单帧耗时,长时间稳定在 33 毫秒以内说明逻辑没跑偏;第二,开着任务管理器盯 GDI 对象列,挂机半小时数值不动才是健康的,持续上涨就是泄漏,回到第 5 章逐个查;第三,把帧耗时实时刷新到窗口标题栏,一行 SetWindowTextW 的事,这是最省事的性能面板。

我现在的习惯是:每次改完绘制或逻辑,先不急着玩,开着任务管理器把程序挂机半小时,再回来检查帧率和 GDI 占用。画面不花、数值不涨、内存平稳,比任何单元测试都让我安心。希望帮到你。

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

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

中小独立站ERP替代方案:轻量自动化与垂直工具选型指南

1. 为什么中小独立站老板总在店小秘ERP门口反复徘徊&#xff1f;“店小秘”这三个字&#xff0c;在跨境独立站圈子里&#xff0c;几乎等同于“老熟人”。我最早接触它是在2019年帮朋友搭一个Shopify速卖通组合的轻量级站&#xff0c;当时团队就3个人&#xff1a;运营、美工、兼…

作者头像 李华
网站建设 2026/9/24 19:03:30

MFC版植物大战僵尸:C++教学塔防工程实战解析

简介&#xff1a;一份基于MFC的C《植物大战僵尸》复刻项目&#xff0c;面向具备基础C语法、想了解Windows桌面游戏开发的初学者。资源包含完整工程源码&#xff0c;共15个文件&#xff0c;以6个头文件和3个CPP源文件为主&#xff0c;辅以解决方案与项目配置&#xff08;sln、vc…

作者头像 李华
网站建设 2026/9/24 19:03:29

LSTM网络流量预测实战:从数据预处理到多步预测避坑指南

简介&#xff1a;循环神经网络&#xff08;RNN&#xff09;与长短期记忆网络&#xff08;LSTM&#xff09;是处理时间序列数据的经典模型&#xff0c;本压缩包提供一份用Python实现的LSTM网络流量预测源码&#xff0c;面向机器学习初学者、网络运维人员及对时序预测感兴趣的开发…

作者头像 李华
网站建设 2026/9/24 19:02:54

Fedora下用oh-my-posh美化bash提示符实战指南

Fedora 默认的 bash 提示符&#xff0c;说实话&#xff0c;用久了确实有点朴素。主机名冒号、当前目录、美元符号&#xff0c;一条干巴巴的横线怼在屏幕最底下&#xff0c;看多了难免想折腾点花样。oh-my-posh 这个工具这几年在终端美化圈里口碑不错&#xff0c;最初是 PowerSh…

作者头像 李华
网站建设 2026/9/24 19:02:52

YOLOv8实战:从校园人脸识别到公路车辆检测的完整流程

简介&#xff1a;一套基于YOLOv8的智慧校园人脸识别与公路汽车检测综合项目资源&#xff0c;面向目标检测和人脸识别方向的学习者&#xff0c;适用于毕业设计、课程设计或工程实训。项目核心流程包括&#xff1a;利用yolov8l-face模型检测并跟踪校园门口的人脸&#xff0c;再借…

作者头像 李华
网站建设 2026/9/24 19:02:13

Gitee与GitCode怎么选?代码托管平台对比与实操避坑指南

先说结论&#xff1a;如果你在国内做开源项目、带学生团队、放个人博客&#xff0c;或者纯粹想找一个访问速度快、中文文档友好的代码托管平台&#xff0c;Gitee依然是最稳的选择&#xff1b;如果你更看重现代IDE体验、GitLab风格的工作流、企业级Code Review&#xff0c;以及想…

作者头像 李华