尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

MFC俄罗斯方块实战:从核心逻辑到GDI双缓冲绘制全解析

MFC俄罗斯方块实战:从核心逻辑到GDI双缓冲绘制全解析 简介这是一套基于MFC框架实现的俄罗斯方块游戏源码面向C桌面开发初学者与Windows程序设计课程学习者适合作为课程设计或MFC入门练手项目。代码围绕游戏核心逻辑展开包含方块类设计、游戏板状态管理、移动旋转、消行判定等模块并借助对话框程序搭建界面与交互框架可以帮助读者对照源码理解MFC的消息响应机制和图形绘制流程。压缩包共33个文件以h头文件与cpp源文件为主配合rc资源脚本、ico图标和bmp位图构成可编译运行的完整工程整体大小仅1.26MB结构精简。目前已有60人学习下载。阅读源码可掌握MFC对话框应用的项目组织、方块绘制、定时器驱动游戏循环及消行算法对后续开发类似小游戏或深入理解MFC框架都有直接参考价值。1. 为什么我决定用 MFC 重写一个俄罗斯方块——先把标题说透很多人一听到 MFC 俄罗斯方块第一反应是“这年头谁还用 MFC 写游戏”。但换个角度看俄罗斯方块逻辑密集、绘制简单、交互明确恰恰是把 MFC 的消息循环、定时器、GDI 绘图和内存管理整套基本功串起来的最佳载体。它不解决游戏引擎选型问题它解决的是“你能不能用手上最笨的框架把一套完整交互逻辑稳稳跑起来”的问题。我拿它做过新人的入职练习也拿它验证过老项目的架构结论都一样能独立写完这个项目的人对 MFC 的掌握基本不会差。这篇文章适合两类人一类是刚接触 MFC 对话框程序、想找个项目练手的学生或转行者另一类是工作中要用 MFC 做工具软件、需要快速搭一个带实时界面刷新的小模块的工程师。我会把方块描述、旋转碰撞、消行计分、双缓冲绘图、定时器驱动、键盘响应和内存泄漏排查全部拆开讲每个环节都给了能直接抄走的代码和参数。最后那章会告诉你几个我每次做完都会跑一遍的验证用例——毕竟游戏能做出来是一回事能拿给别人玩是另一回事。2. 把游戏逻辑先立住方块、旋转与消除是怎么算的2.1 七种方块用 4x4 矩阵描述为什么不把 O 和 I 特殊处理俄罗斯方块的核心是一个 10x20 的场地和七种标准方块。描述方块最省事的方案是 4x4 矩阵有格子的地方填 1空的地方填 0。比如 T 形方块在基准状态下是static const int T_BLOCK[4][4] { {0, 0, 0, 0}, {0, 1, 0, 0}, {1, 1, 1, 0}, {0, 0, 0, 0} };七种方块全部用这种 4x4 矩阵定义而不是用坐标数组。原因有三点第一旋转就是矩阵转置加行列交换代码统一不用为每种方块写特殊的旋转规则第二碰撞检测时把矩阵逐格映射到场地坐标逻辑单一第三4x4 对你视觉上没有干扰调试时在输出窗口里直接打印矩阵结构一眼能看穿。有人会建议把 O 形和 I 形单独处理说 O 形不需要旋转、I 形旋转后只有一行或一列。我的经验是不要特判。O 形旋转后矩阵不变天然是安全的I 形在 4x4 里旋转后确实会出现只有一行或一列的情况但碰撞检测只关心非零格子零行零列不参与计算所以统一走同一套旋转和碰撞逻辑完全没问题。特判只会增加分支带来不必要的测试面。2.2 碰撞检测的三个判定点左墙、右墙、已堆叠区碰撞检测是游戏正确性的地基。我一般把移动拆成横向移动left/right和纵向下落down两种情况统一走一个检测函数bool CGameCore::CanMove(int dx, int dy) { for (int row 0; row 4; row) { for (int col 0; col 4; col) { if (m_shape[row][col] 0) continue; // 空格子不参与 int nx m_curX col dx; int ny m_curY row dy; if (nx 0 || nx COL_COUNT) return false; // 碰左墙或右墙 if (ny ROW_COUNT) return false; // 碰底 if (ny 0 m_board[ny][nx] ! 0) return false; // 碰已堆叠方块 } } return true; }COL_COUNT 是 10ROW_COUNT 是 20。m_curX 和 m_curY 是当前方块左上角在场地坐标系里的位置注意只取非零格子的场地坐标来判断这样 I 形旋转后的空行空列不会造成误判。这里最容易漏的是 ny 0 这个条件因为新方块刚生成时 m_curY 可能为负比如方块在场地上方等待进场如果不对 ny 做下界保护负坐标会被错误地当成“越界”或者直接索引负数组下标程序就会在你不注意的时候崩掉。2.3 旋转的墙踢为什么旋转到贴墙时会莫名其妙穿墙旋转只是对 4x4 矩阵做顺时针变换但经常出现的问题是方块贴墙时一按旋转新矩阵里多出来的部分直接跑到了墙外面。基础旋转代码是这样void CGameCore::RotateClockwise() { int temp[4][4] {0}; for (int i 0; i 4; i) { for (int j 0; j 4; j) { temp[j][3 - i] m_shape[i][j]; // 顺时针旋转 } } // 先尝试原地旋转不碰撞就接受 if (CanPlace(m_curX, m_curY, temp)) { memcpy(m_shape, temp, sizeof(temp)); return; } // 墙踢依次尝试左移1格、右移1格、左移2格 for (int offset 1; offset 2; offset) { if (CanPlace(m_curX - offset, m_curY, temp)) { m_curX - offset; memcpy(m_shape, temp, sizeof(temp)); return; } if (CanPlace(m_curX offset, m_curY, temp)) { m_curX offset; memcpy(m_shape, temp, sizeof(temp)); return; } } }CanPlace 和 CanMove 的区别在于 CanPlace 直接拿目标坐标检测整个矩阵不叠加当前偏移。墙踢的 offset 最多到 2是因为 4x4 方块贴墙时旋转导致的最多凸出两列。加了这一层玩家在墙边按旋转不会觉得方块“卡死”手感会好很多。2.4 消行逻辑从下往上扫一次消多行是怎么实现的消行是最容易写错的地方。常见错误是用两层循环把满行删掉然后继续往下遍历结果漏掉连续满行。我用的方案是从底往上扫遇到满行就把它上面所有行整体下移一行清空顶行然后把当前行号重新检查一遍int CGameCore::ClearLines() { int lines 0; for (int y ROW_COUNT - 1; y 0; y--) { bool full true; for (int x 0; x COL_COUNT; x) { if (m_board[y][x] 0) { full false; break; } } if (full) { // 上面的行逐行下移 for (int yy y; yy 0; yy--) { memcpy(m_board[yy], m_board[yy - 1], sizeof(m_board[0])); } memset(m_board[0], 0, sizeof(m_board[0])); y; // 因为下移后当前行被新内容填了要重新查一次 lines; } } return lines; }这段代码里 y 是关键。如果一次消掉两行第一行消完下移后第二行已经补到当前 y 的位置不重新检查就会漏掉它。计分规则跟随经典俄罗斯方块的约定1 行 100 分2 行 300 分3 行 500 分4 行 800 分也就是一次消的行数越多单行收益越高这样玩家才会追求竖棍留长条。3. 把 MFC 对话框程序变成游戏主机界面、绘图与键盘响应3.1 用对话框程序而不是文档视图理由是什么MFC 应用向导通常会给你三个选择单文档、多文档、对话框。做俄罗斯方块我直接选对话框程序理由很实际游戏不需要文档序列化不需要多窗口不需要拆分视图。一个主对话框加一个自定义绘图区域逻辑和界面全部内聚在一个 CWnd 派生类里代码量最少调试路径最短。如果你手头只有面向文档视图的 MFC 框架也不是不能做但你要处理 CView 的 OnDraw 和文档刷新机制等于把简单问题复杂化。对话框程序下要做的事只有三件放一个 Picture Control 作为游戏场地、放几个按钮和 Static Text 作为控制区和记分板、自己管理绘图和定时器。还有一点要注意MFC 组件本身是需要单独勾选安装的VS 安装器默认不会把 MFC 库装全装完以后新建项目如果找不到 MFC 模板回去把“使用 C 的桌面开发”工作负载里的 MFC 组件补上就行。3.2 OnPaint 只做一件事把内存位图整块贴到窗口直接往窗口 DC 上画矩形块画面会闪到你怀疑人生。原因很简单窗口每次刷新时系统默认先擦掉背景再触发 OnPaint擦和画之间有一小段空白人眼看起来就是整体闪烁。标准解法是双缓冲先把所有内容画到一张内存位图里再一次 BitBlt 贴上去。核心绘制函数我一般放在对话框类的成员函数里由 OnPaint 调用void CTetrisDlg::DrawGame() { CClientDC dc(this); CRect rc; GetClientRect(rc); CDC memDC; memDC.CreateCompatibleDC(dc); CBitmap bmp; bmp.CreateCompatibleBitmap(dc, rc.Width(), rc.Height()); CBitmap* pOldBmp memDC.SelectObject(bmp); // 1. 画背景 memDC.FillSolidRect(rc, RGB(40, 40, 40)); // 2. 画场地网格 memDC.FillSolidRect(m_gameRect, RGB(20, 20, 20)); DrawGrid(memDC); // 3. 画已堆叠的方块 DrawPlacedBlocks(memDC); // 4. 画当前下落的方块 DrawCurrentBlock(memDC); // 5. 把整幅内存图贴到窗口 dc.BitBlt(0, 0, rc.Width(), rc.Height(), memDC, 0, 0, SRCCOPY); // 6. 恢复并收尾 memDC.SelectObject(pOldBmp); bmp.DeleteObject(); memDC.DeleteDC(); }每个绘制格子时建议统一走一个 DrawCell 函数通过行列索引换算像素坐标格宽 场地区域宽度除以 COL_COUNT格高 场地区域高度除以 ROW_COUNT。注意 CreateCompatibleDC 需要传入一个真实存在且与窗口 DC 兼容的设备上下文件这里用 CClientDC 拿到客户区 DC 就是为这个准备。内存 DC 和位图用完要清理否则程序跑几分钟任务管理器里 GDI 对象数会一路涨后面避坑章节我会专门说。3.3 SetTimer 驱动游戏节奏OnTimer 里只做“该下落就下落”这一件事游戏循环在 MFC 里最自然的实现不是多线程而是定时器。OnInitDialog 里初始化好场地后调用一次 SetTimer#define TIMER_GAME 1 // 在 OnInitDialog 末尾调用 void CTetrisDlg::StartGame() { // 重置场地、分数、等级 m_game.Reset(); m_nLevel 1; m_nScore 0; // 等级越高间隔越短 int interval 500 - (m_nLevel - 1) * 40; // 等级1约500ms等级10约140ms SetTimer(TIMER_GAME, interval, NULL); }OnTimer 里只做“推进一帧”的事当前方块能否继续下落能就下落不能就固定到场地、消行、生成新方块。这里的关键是不要把任何绘图逻辑写进 OnTimer 的业务分支里OnTimer 只负责改游戏状态状态改完统一调用 Invalidate 触发重绘或者直接调用 DrawGame。逻辑和渲染分开后面调难度、加动画效果时才不会牵一发动全身。3.4 键盘控制走 PreTranslateMessage避免对话框消息被吃对话框程序的默认焦点在某一个控件上方向键按下时 WM_KEYDOWN 会发给当前焦点控件。如果你只在对话框类里重写 OnKeyDown会发现按方向键完全没反应因为对话框根本收不到键盘消息。解决办法是重写 PreTranslateMessage在消息被派发到控件之前先拦截BOOL CTetrisDlg::PreTranslateMessage(MSG* pMsg) { if (pMsg-message WM_KEYDOWN) { switch (pMsg-wParam) { case VK_LEFT: if (m_game.Move(-1, 0)) DrawGame(); return TRUE; case VK_RIGHT: if (m_game.Move(1, 0)) DrawGame(); return TRUE; case VK_DOWN: if (m_game.Move(0, 1)) DrawGame(); return TRUE; case VK_UP: m_game.RotateBlock(); DrawGame(); return TRUE; case VK_SPACE: m_game.HardDrop(); // 硬降到底 DrawGame(); return TRUE; default: break; } } return CDialogEx::PreTranslateMessage(pMsg); }PreTranslateMessage 里处理完按键后必须 return TRUE表示这条消息已经被消费掉否则还会继续往控件派发。另一个细节是回车键如果对话框上有“开始”按钮回车会触发默认按钮导致你玩游戏时按回车突然重启游戏。如果你的设计里回车没有用途建议把 VK_RETURN 也在 PreTranslateMessage 里拦截并 return TRUE或者在设计器里把默认按钮属性清掉。4. MFC 俄罗斯方块最容易翻车的 5 个坑现象、原因、解决4.1 画面疯狂闪烁眼睛都花了新手第一次跑通绘制时最常见的就是闪屏方块每落一格整个窗口就闪一次严重的时候连图形都看不清。原因是窗口收到 WM_ERASEBKGND 消息时默认用背景色把客户区刷一遍然后 OnPaint 里再画新内容擦除和绘制交错画面就会高频率闪烁。解决方法是双缓冲 拦截 WM_ERASEBKGND。双缓冲的代码我在前面已经给过拦擦除背景只需要重写 OnEraseBkgndBOOL CTetrisDlg::OnEraseBkgnd(CDC* pDC) { // 什么都不画直接告诉系统背景已经“擦”完了 return TRUE; }返回 TRUE 之后系统不会再额外擦背景所有绘制统一走 OnPaint 里的内存 DC一次成型贴上去。如果在 OnEraseBkgnd 里调用了 CDialogEx::OnEraseBkgnd又会触发一次默认擦除等于白改所以一定要直接返回 TRUE 绕开父类。4.2 运行几分钟内存持续上涨GDI 对象没释放我的项目跑到第三分钟任务管理器里 GDI 对象数稳定上涨到几千个内存蹭蹭往上走最后系统开始卡顿。原因不是普通内存泄漏而是 GDI 对象泄漏每次 DrawGame 里 CreateCompatibleDC 和 CreateCompatibleBitmap 创建的对象没有被释放。GDI 对象是系统级资源进程退出前不强制回收窗口程序长期运行就会吃光系统资源。解决方式是三类对象分别收拾干净。DC 用完 DeleteDC位图用完 DeleteObject选入内存 DC 的旧对象在函数退出前恢复。前面 3.2 里代码最后三行的顺序值得再强调一次先 SelectObject 把原来的位图选回去再 DeleteObject 删除临时位图最后 DeleteDC 释放内存 DC。如果你把顺序写反了先删了位图再把旧位图选回去就会选回一个已销毁的对象程序当时不报错后面某次随机会崩溃。这种问题靠调试器不好定位因为报错点离泄漏点很远。4.3 定时器越跑越快或者按一次方向键移动了两格出现这个问题的典型场景是在“开始”按钮的响应函数里写了 SetTimer每次点击都重新设置一次。如果代码里在 SetTimer 之前没有 KillTimer同一个定时器 ID 会被反复重置但游戏逻辑的推进还会叠加肉眼表现就是方块下落速度越来越快。按一次方向键移动两格则是另一个原因方向键的 WM_KEYDOWN 触发后系统在按住不放时会自动重发 WM_KEYDOWN 消息你的 PreTranslateMessage 没有判断重复标记就会连续移动。解决定时器问题的统一管理方式对话框 OnInitDialog 里初始化好定时器状态StartGame 方法一开始就 KillTimer 再 SetTimervoid CTetrisDlg::StartGame() { KillTimer(TIMER_GAME); // 先清理旧定时器 ResetGame(); int interval 500 - (m_nLevel - 1) * 40; SetTimer(TIMER_GAME, interval, NULL); }还有 WM_TIMER 的默认处理是每到点就触发一次你不需要也不应该自己再写循环。窗口销毁时记得在 OnDestroy 里 KillTimer否则对话框关掉后定时器可能还会继续触发消息。按键重复问题则在 PreTranslateMessage 里检查 lParam 的第 30 位if (pMsg-message WM_KEYDOWN) { bool bRepeat (pMsg-lParam (1 30)) ! 0; if (bRepeat pMsg-wParam VK_LEFT) { // 按住不放时的自动重复这里直接忽略或间隔处理 return TRUE; } }简单做法是按住方向键时只接受第一次按下硬降、旋转这类操作不受重复影响下落加速则继续响应 VK_DOWN 的重复以支持“按住连续快降”。4.4 方向键没反应对话框焦点把键盘消息吃了我在 3.4 说过 PreTranslateMessage这里再补一个更隐蔽的情况你的对话框上可能有一个默认实例化的按钮获得了焦点此时方向键发给了按钮按钮不会处理。即便你重写 OnKeyDown 也是没用的因为对话框本身收不到这份键盘消息。现象是按方向键完全没反应但鼠标点一下窗口空白处再按方向键又好了这个“时好时坏”的特征就是焦点在捣鬼。解决是双保险一方面用 PreTranslateMessage 在派发前拦截另一方面在游戏运行时主动把焦点从按钮上移开例如把焦点 SetFocus 到对话框本身或绘图区域。如果你用的是 Picture Control 作为场地可以给它设置 SS_NOTIFY 样式并处理它的焦点行为。这样即使 PreTranslateMessage 失效键盘消息也会因为焦点在可接收输入的区域而被正确处理。4.5 CString 拼接乱码或调试输出里出现 dumpcont.cpp(23) 的 ATLTRACE 信息游戏界面上要显示分数、等级、消行数常规做法是 CString 拼静态文本。如果工程配置是“多字节字符集”而你又混用了 CStringA 和 CStringW拼接时会发生编码转换显示乱码还是小事严重时会产生内存访问异常。调试输出窗口里如果出现类似 f:\dd\vctools\vc7libs\ship\atlmfc\src\mfc\dumpcont.cpp(23) : ATLTRACE general 这样的信息多半和 CString 内部构造析构或引用计数异常有关。解决思路是把整个工程统一到 Unicode 字符集下。VS 里项目属性 → 常规 → 字符集选“使用 Unicode 字符集”然后所有字符串全部用 CString 或 CStringW不要用 char* 或 CStringA 参与游戏逻辑接口。格式化输出用 _T 和 stprintfCString strInfo; strInfo.Format(_T(分数: %d 等级: %d 行数: %d), nScore, nLevel, nLines); GetDlgItem(IDC_STATIC_INFO)-SetWindowText(strInfo);如果确实要看内存泄漏信息可以在程序入口加 _CrtSetDbgFlag(_CRTDBG_ALLOC_MEM_DF | _CRTDBG_LEAK_CHECK_DF)关闭程序后在输出窗口会列出泄漏块编号再配合 _CrtSetBreakAlloc 下断点定位。这样比用任务管理器看内存上涨要准得多。5. 把它做成能给别人玩的成品分数、等级、预览与验证5.1 下一块预览与等级速度改动三个值就能让节奏完全不同一个能拿出手的俄罗斯方块至少要包含下一块预览、分数、等级和速度变化。预览的做法是在对话框的右上角放一个静态区域游戏核心类里存一个 m_nextShape生成新方块时把 m_nextShape 赋给当前方块再随机生成下一个。绘制预览和绘制当前方块共用同一套 DrawShape 函数只是坐标系不同。等级和速度的映射我一般用线性表等级定时器间隔说明1500ms起步给玩家适应时间3420ms新手开始感到压力5340ms中间难度8220ms需要预判10140ms接近硬降阈值手动操作上限1550ms几乎等于持续下落等级提升条件我习惯每消 10 行升 1 级升级时 KillTimer 后重新 SetTimer。这个参数不用追求绝对平衡关键是要把间隔和等级做成一个可调的映射函数你后期测试时改一个系数就能整体调速。如果想让玩家的感觉更平滑可以采用非线性公式 interval 500 * pow(0.9, level-1)等级越高速度提升越不明显这样后期不会突然快到没法玩。5.2 用调试输出和手动用例验证逻辑三个可以复现的边界用例写完逻辑后我习惯不开界面直接在 CGameCore 上写临时验证代码用 TRACE 输出结果来跑三个固定用例。这样可以确认核心逻辑可靠再进界面调手感。第一个用例是硬降到底把当前方块放在场地顶部调用 HardDrop然后检查它是否停在最低可用行并且 m_board 对应行已经写入了方块。第二个用例是单行与多行消除手工构造一行只剩一个空位放一个竖棍补满它触发消行检查行数计分。第三个用例是贴墙旋转把 T 形方块左边界贴到墙调用 RotateBlock验证墙踢逻辑把方块右移了一格而不是拒绝旋转。每个用例跑完用 TRACE 输出关键状态TRACE(_T(用例1: 硬降后 y%d, isPlaced%d\n), m_curY, m_board[m_curY][3]); TRACE(_T(用例2: 消行数%d, 得分%d\n), nLines, nScore);早期就搭好这套验证后面加功能时跑一遍就知道有没有回归破坏。我个人的习惯是写完逻辑先不急着连界面用控制台工程或者临时测试函数把 CGameCore 验证完再接 MFC 界面调按键和定时器。这样定位问题时的搜索范围能缩小一半逻辑错就改核心类界面错就调对话框不用每次都在两者之间来回翻。等到界面、键盘、分数、预览全部联调通过整个 MFC 俄罗斯方块就算真正完工了。希望这篇分享能帮你少踩几个坑把项目稳稳做出来。本文还有配套的精品资源点击获取
返回列表