ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

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

2026/9/24 19:05:09 拓冰建站 浏览量
用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() 的分布质量差而且每次运行序列固定。C11 的 库里 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 FPSMFC 的消息循环根本跑不到那么快反而让 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 版默认链接调试版运行库 /MTdRelease 版链接 /MT两者对 std::string 的内存布局、边界检查约定都不完全一致。如果你在存档文件里直接写入 std::string 的内部内存结构跨配置读取必然翻车。再加上工程里 CString 和 std::string 混用MFC 默认 Unicode、外部素材文件名却是 ANSI两套编码一混叠乱码和崩溃就一起来。解决所有工程配置统一用 Unicode 字符集字符串类型要么全用 CString要么全用 std::wstring杜绝混用。存档文件写成可读文本格式最省事的是简单用逗号分隔或 JSON不要直接 dump 内存结构。跑 Release 前先全面读档一次把 Debug 和 Release 的存档文件分开存放在不同目录里避免互相污染。6. 用 C17 收尾智能指针接管对象生命周期顺手验证帧率与 GDI 占用游戏主循环跑通后还有一个容易被忽略的收尾工作对象内存管理。MFC 老代码习惯用裸 new 和 delete对象一多在新增和移除时就容易出错。C17 的 smart pointer 在这里正好派上用场这也是 2025 年写 C 的默认姿势。6.1 用 std::unique_ptr 接管游戏对象容器游戏里的植物、僵尸、子弹都在运行中不断创建和销毁用裸指针容器管理最怕的是某个分支忘了 delete。用 unique_ptr 之后对象生命周期跟容器条目绑定移除条目时自动释放内存。#include memory // 对象容器持有派生类对象的唯一所有权 std::vectorstd::unique_ptrCGameObject m_objects; // 创建一棵新植物 auto plant std::make_uniqueCSunflower(row, col); m_objects.push_back(std::move(plant)); // 每帧结束统一清理死亡对象 auto it std::remove_if(m_objects.begin(), m_objects.end(), [](const std::unique_ptrCGameObject 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 版本编译器支持 C17VS2019 及以后都支持还能用 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 占用。画面不花、数值不涨、内存平稳比任何单元测试都让我安心。希望帮到你。本文还有配套的精品资源点击获取