
简介这是一份面向MFC初学者与Windows游戏开发入门者的实战源码以经典俄罗斯方块为载体帮助读者理解对话框框架下如何组织游戏逻辑与界面交互。资源包共61个文件约19.92MB包含12个h头文件与11个cpp源文件承载核心类实现4个bmp位图与ico、rc等资源文件负责界面素材另有sln、vcproj工程文件及exe可执行程序方便直接编译运行与调试。已有278人学习下载。项目围绕CDialog派生类展开通过OnInitDialog初始化控件、消息映射响应按钮点击并借助定时器控制方块下落节奏方块生成、旋转、碰撞检测与整行消除等算法在独立类中实现得分显示与操作按钮则复用CStatic、CButton等控件。随包附带的《手把手教你制作俄罗斯方块.doc》可辅助对照代码理解整体流程适合作为课程设计、自学练手或二次扩展的基础工程。1. 对话框版俄罗斯方块为什么老工程师还在用 MFC 手搓这个很多人第一次接触 Windows 桌面开发就是从 MFC 对话框程序开始的。而俄罗斯方块这个题目几乎是每个学 C 的人绕不开的练手项目——它足够小小到一个下午能跑通又足够完整完整到能覆盖消息循环、定时器、绘图、键盘响应、状态机这一整套桌面开发的核心机制。把这两件事叠在一起「MFC 俄罗斯方块基于对话框」就成了一个非常典型的工程练习不依赖游戏引擎不依赖第三方库纯 Win32 MFC 就能跑起来。这篇文章面向的是想真正把 MFC 对话框机制吃透的人。你可能已经看过不少「C 俄罗斯方块代码」但那些大多是控制台版本跟窗口消息、GDI 绘图、资源编辑器完全是两回事。基于对话框的版本核心在于用CDialogEx派生类承载整个游戏循环用OnTimer驱动下落用OnPaint做双缓冲绘制用PreTranslateMessage拦截方向键。这套结构跑通之后你再去看「mfc 制作一个近似于 windows 记事本怎么做」这类问题思路是相通的——都是对话框/框架窗口 消息映射 控件交互。适合谁读有 C 基础、装好了 Visual Studio、想找一个能完整跑通且能继续扩展的 MFC 实战项目的人。如果你还在纠结「visual studio 离线安装 mfc」怎么配建议先把工作负载里的「使用 C 的桌面开发」勾上确保afxwin.h能正常包含再往下看。2. 对话框骨架怎么搭从资源编辑器到消息映射2.1 为什么选对话框而不是单文档/多文档MFC 有四大类应用模板单文档SDI、多文档MDI、对话框Dialog-based、多顶级文档。俄罗斯方块这种游戏没有文档概念不需要菜单栏和工具栏的完整框架对话框模板反而是最轻的选择。它天然带一个CDialogEx派生类DoDataExchange机制可以挂控件变量资源编辑器里拖一个静态文本框就能当画布占位。更关键的是对话框程序的消息循环是现成的。你不需要自己写GetMessage/TranslateMessage/DispatchMessageMFC 已经封装好了。你要做的只是往消息映射表里加条目。对于游戏来说定时器消息WM_TIMER和键盘消息WM_KEYDOWN是两条主线对话框框架都能直接接住。常见做法是新建项目时选「MFC 应用」应用程序类型选「基于对话框」资源语言选中文其余默认。生成后你会得到CXXXApp和CXXXDlg两个核心类外加一个.rc资源文件。游戏逻辑全部塞进CXXXDlg里就够了。2.2 资源编辑器里的最小布局打开资源视图找到IDD_XXX_DIALOG把默认的「确定」「取消」按钮删掉拖一个Static Text控件进来ID 改成IDC_CANVAS属性里把Sunken勾上这样有个凹陷边框视觉上像游戏区域。再拖两个按钮一个「开始」ID 设IDC_BTN_START一个「暂停」ID 设IDC_BTN_PAUSE。画布尺寸怎么定俄罗斯方块标准是 10 列 × 20 行。每个格子按 24 像素算画布就是 240 × 480。在OnInitDialog里用SetWindowPos把静态控件调整到这个尺寸或者直接在资源编辑器里拖到差不多大小运行时再精确设置。// OnInitDialog 中设置画布尺寸 CRect rcCanvas(0, 0, 240, 480); GetDlgItem(IDC_CANVAS)-SetWindowPos(NULL, 0, 0, rcCanvas.Width(), rcCanvas.Height(), SWP_NOMOVE | SWP_NOZORDER);这段代码把IDC_CANVAS这个静态控件的客户区固定成 240×480。SWP_NOMOVE表示不改变位置SWP_NOZORDER表示不调整 Z 序。参数里的宽高就是 10×24 和 20×24 算出来的。如果你想让格子大一点改成 30 像素宽高就变成 300×600但要注意对话框本身也得跟着放大否则控件会被裁掉。2.3 消息映射表里必须加的三条MFC 的消息映射靠BEGIN_MESSAGE_MAP和END_MESSAGE_MAP之间的宏。对话框程序默认只有ON_WM_SYSCOMMAND和ON_WM_PAINT之类的基础条目。你要手动加三条BEGIN_MESSAGE_MAP(CTetrisDlg, CDialogEx) ON_WM_SYSCOMMAND() ON_WM_PAINT() ON_WM_TIMER() // 定时器驱动下落 ON_WM_KEYDOWN() // 备用键盘入口 ON_BN_CLICKED(IDC_BTN_START, CTetrisDlg::OnBnClickedBtnStart) ON_BN_CLICKED(IDC_BTN_PAUSE, CTetrisDlg::OnBnClickedBtnPause) END_MESSAGE_MAP()ON_WM_TIMER对应OnTimer(UINT_PTR nIDEvent)用来处理方块自动下落。ON_WM_KEYDOWN对应OnKeyDown但对话框里方向键会被控件截获所以后面还要用PreTranslateMessage补一刀。两个按钮的ON_BN_CLICKED是标准控件通知点一下触发对应函数。注意如果你在资源编辑器里改了按钮 ID消息映射里的 ID 必须同步改否则编译能过但点击没反应这是新手最常见的翻车点之一。3. 游戏核心逻辑方块数据结构与下落碰撞3.1 用二维数组还是位运算存方块俄罗斯方块有 7 种标准方块I、O、T、S、Z、J、L。每种方块有 4 个旋转态。常见做法是用一个 4×4 的二维数组表示每种形态旋转就是矩阵转置加翻转。也有用位运算压缩的一个uint16_t存 16 个格子的占用状态旋转靠位移和掩码。后者省内存但可读性差调试时不容易看。我一般会选二维数组方案因为代码直观出问题好排查。定义如下// 7 种方块每种 4 个旋转态每个态用 4x4 矩阵 const int SHAPES[7][4][4][4] { // I {{{0,0,0,0},{1,1,1,1},{0,0,0,0},{0,0,0,0}}, {{0,0,1,0},{0,0,1,0},{0,0,1,0},{0,0,1,0}}, {{0,0,0,0},{0,0,0,0},{1,1,1,1},{0,0,0,0}}, {{0,1,0,0},{0,1,0,0},{0,1,0,0},{0,1,0,0}}}, // O {{{0,0,0,0},{0,1,1,0},{0,1,1,0},{0,0,0,0}}, {{0,0,0,0},{0,1,1,0},{0,1,1,0},{0,0,0,0}}, {{0,0,0,0},{0,1,1,0},{0,1,1,0},{0,0,0,0}}, {{0,0,0,0},{0,1,1,0},{0,1,1,0},{0,0,0,0}}}, // T、S、Z、J、L 省略结构相同 };每个SHAPES[type][rotation][row][col]里的 1 表示该格子被占用。type取 0 到 6rotation取 0 到 3。绘制时遍历这个 4×4 矩阵遇到 1 就画一个方块。碰撞检测也是遍历这个矩阵把方块当前坐标加上矩阵偏移去查背景网格是否已被占用或越界。参数说明4×4 是最小能容纳所有旋转态的尺寸。I 型方块横着占 4 格竖着也占 4 格所以必须 4×4。O 型实际只占 2×2但为了统一处理周围补 0。这样旋转逻辑对所有方块一致不需要特判。3.2 背景网格与碰撞检测函数背景用一个int board[20][10]表示0 表示空非 0 表示已固定方块的颜色索引。碰撞检测函数接收方块类型、旋转态、当前行列偏移返回是否合法bool CTetrisDlg::CheckCollision(int type, int rot, int row, int col) { for (int r 0; r 4; r) { for (int c 0; c 4; c) { if (SHAPES[type][rot][r][c] 0) continue; int br row r; int bc col c; // 越界或撞到已固定方块 if (bc 0 || bc 10 || br 20) return true; if (br 0 board[br][bc] ! 0) return true; } } return false; }逻辑很直白遍历方块矩阵只检查值为 1 的格子。br和bc是映射到背景网格的坐标。左右越界、底部越界、撞到已有方块三种情况都返回true表示碰撞。注意br 0的情况不判碰撞因为方块刚生成时可能有一部分在顶部之上这是允许的。参数怎么调如果你想让方块生成时完全在可视区域内可以把初始row设成 0 而不是负数。但标准俄罗斯方块允许方块从顶部之外进入所以初始row通常是 -1 或 -2让方块看起来是从上面掉下来的。3.3 定时器驱动下落与消行OnTimer里做三件事尝试下移一行如果碰撞就固定方块、检查消行、生成新方块。void CTetrisDlg::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent TIMER_DROP !m_bPaused m_bRunning) { if (!CheckCollision(m_nType, m_nRot, m_nRow 1, m_nCol)) { m_nRow; // 能下移就下移 } else { LockPiece(); // 固定到背景 int lines ClearLines(); // 消行 m_nScore lines * 100; SpawnPiece(); // 生成新方块 if (CheckCollision(m_nType, m_nRot, m_nRow, m_nCol)) { m_bRunning FALSE; // 新方块一出生就撞游戏结束 KillTimer(TIMER_DROP); } } Invalidate(FALSE); // 触发重绘 } CDialogEx::OnTimer(nIDEvent); }TIMER_DROP是定时器 ID在OnInitDialog里用SetTimer(TIMER_DROP, 500, NULL)创建500 毫秒下落一格。LockPiece把当前方块矩阵写入boardClearLines从下往上扫描满行删掉后把上面的行整体下移。SpawnPiece随机选一个类型旋转态归零行列设成初始值。提示Invalidate(FALSE)的FALSE表示不擦除背景配合双缓冲能减少闪烁。如果你发现画面闪得厉害八成是这里传了TRUE或者没做双缓冲。4. 绘图与键盘双缓冲和 PreTranslateMessage 拦截4.1 双缓冲解决闪烁MFC 对话框的OnPaint默认直接在屏幕 DC 上画每次重绘都先擦背景再画前景方块一多就闪。标准解法是内存 DC先画到内存位图再一次性BitBlt到屏幕。void CTetrisDlg::OnPaint() { CPaintDC dc(this); CWnd* pCanvas GetDlgItem(IDC_CANVAS); if (!pCanvas) return; CRect rc; pCanvas-GetClientRect(rc); CDC memDC; memDC.CreateCompatibleDC(dc); CBitmap bmp; bmp.CreateCompatibleBitmap(dc, rc.Width(), rc.Height()); CBitmap* pOld memDC.SelectObject(bmp); // 先填黑背景 memDC.FillSolidRect(rc, RGB(0, 0, 0)); DrawBoard(memDC); // 画已固定方块 DrawPiece(memDC); // 画当前下落方块 dc.BitBlt(rc.left, rc.top, rc.Width(), rc.Height(), memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOld); }CreateCompatibleDC创建与屏幕兼容的内存 DCCreateCompatibleBitmap创建同尺寸位图。所有绘制操作先落在memDC上最后BitBlt一次性拷贝。SRCCOPY表示直接覆盖。这样屏幕上只看到最终结果中间过程不可见闪烁就消失了。参数说明rc是画布控件的客户区矩形宽高应该是 240×480。如果你改了格子大小这里自动适配。FillSolidRect填黑色背景方块颜色用RGB宏指定比如RGB(255, 0, 0)是红色。4.2 PreTranslateMessage 拦截方向键对话框里方向键会被按钮或静态控件吃掉OnKeyDown收不到。必须在PreTranslateMessage里提前截获BOOL CTetrisDlg::PreTranslateMessage(MSG* pMsg) { if (pMsg-message WM_KEYDOWN m_bRunning !m_bPaused) { switch (pMsg-wParam) { case VK_LEFT: if (!CheckCollision(m_nType, m_nRot, m_nRow, m_nCol - 1)) m_nCol--; Invalidate(FALSE); return TRUE; // 消息已处理不再往下传 case VK_RIGHT: if (!CheckCollision(m_nType, m_nRot, m_nRow, m_nCol 1)) m_nCol; Invalidate(FALSE); return TRUE; case VK_UP: { int newRot (m_nRot 1) % 4; if (!CheckCollision(m_nType, newRot, m_nRow, m_nCol)) m_nRot newRot; } Invalidate(FALSE); return TRUE; case VK_DOWN: if (!CheckCollision(m_nType, m_nRot, m_nRow 1, m_nCol)) m_nRow; Invalidate(FALSE); return TRUE; case VK_SPACE: // 硬降 while (!CheckCollision(m_nType, m_nRot, m_nRow 1, m_nCol)) m_nRow; Invalidate(FALSE); return TRUE; } } return CDialogEx::PreTranslateMessage(pMsg); }关键点是return TRUE。这告诉 MFC 消息已经被处理不要再派发给控件。如果不返回TRUE方向键会继续传给按钮可能触发焦点切换。VK_UP做旋转VK_SPACE做硬降这是标准操作映射。注意PreTranslateMessage里不要做耗时操作否则会卡住整个消息循环。碰撞检测是 O(16) 的遍历完全没问题但如果你在里面加载资源或读写文件界面就会卡顿。4.3 绘制方块与颜色映射DrawBoard和DrawPiece都是遍历矩阵遇到非零就画矩形。颜色按方块类型区分COLORREF g_colors[7] { RGB(0, 255, 255), // I 青色 RGB(255, 255, 0), // O 黄色 RGB(128, 0, 128), // T 紫色 RGB(0, 255, 0), // S 绿色 RGB(255, 0, 0), // Z 红色 RGB(0, 0, 255), // J 蓝色 RGB(255, 165, 0) // L 橙色 };画的时候用memDC-FillSolidRect填色再画一个深色边框增加立体感。格子大小CELL 24第r行第c列的矩形是(c*CELL, r*CELL, (c1)*CELL, (r1)*CELL)。背景网格的已固定方块用board里存的颜色索引去查g_colors。5. 避坑与排查对话框版俄罗斯方块最容易翻车的 5 个点5.1 方向键完全没反应现象程序跑起来左右上下按了没动静但按钮点击正常。原因对话框默认会把方向键用于控件焦点导航OnKeyDown根本收不到。很多人只加了ON_WM_KEYDOWN就以为完事了实际上消息在到达OnKeyDown之前就被对话框的IsDialogMessage处理掉了。解决重写PreTranslateMessage在里面判断WM_KEYDOWN并处理方向键处理完返回TRUE。这是对话框程序跟普通窗口程序最大的区别也是「mfc 教程」里经常一笔带过但实际必踩的坑。5.2 画面闪烁严重现象方块下落时整个画布闪消行时闪得更厉害。原因OnPaint里直接往屏幕 DC 画每次Invalidate都先擦背景再画前景两次操作之间屏幕是空的人眼就能看到闪烁。解决用内存 DC 双缓冲。创建兼容位图所有绘制先到内存最后BitBlt一次性输出。另外Invalidate(FALSE)不要传TRUE避免擦背景。如果还闪检查是不是在OnEraseBkgnd里也画了东西把它改成直接返回TRUE屏蔽掉。5.3 方块旋转后位置错乱或穿墙现象按上键旋转方块有时候跑到墙里有时候跟已固定方块重叠。原因旋转后的 4×4 矩阵占用格子变了如果旋转前方块贴着墙旋转后可能越界。碰撞检测没做旋转后的合法性判断直接改了m_nRot。解决旋转前先算新旋转态用CheckCollision验证。不合法就不旋转或者做「踢墙」处理——尝试左右平移一格再旋转。最简单的做法是验证失败就放弃这次旋转用户体验上只是偶尔转不动不会出 bug。5.4 消行后分数不对或行没消干净现象满行消了但上面的行没落下来或者分数加多了。原因ClearLines从下往上扫描时删掉一行后索引变了如果继续用原来的循环变量会漏掉相邻的满行。另外分数计算如果放在循环里一次消多行会重复加。解决ClearLines用两层循环外层从底部往上遇到满行就把该行以上所有行整体下移一行然后row不递增重新检查当前行。分数在函数返回消行总数后统一算不要在里面加。5.5 游戏结束后定时器还在跑现象方块堆到顶了游戏结束提示也弹了但后台定时器还在触发CPU 占用下不来。原因KillTimer没调用或者调用了但 ID 不对。MFC 的SetTimer返回定时器 ID如果你传了自定义 IDKillTimer必须用同一个 ID。解决游戏结束时立刻KillTimer(TIMER_DROP)并把m_bRunning置FALSE。OnTimer开头判断m_bRunning不运行就直接返回。另外对话框关闭时OnDestroy里也要KillTimer防止资源泄漏。如果你在用mfc 字符串内存泄漏排查工具定时器没杀也是常见的泄漏源之一。6. 进阶技巧让这个对话框版俄罗斯方块更耐玩跑通基础版本之后有几个方向可以让它更像一个完整游戏。第一个是「下一个方块预览」在画布旁边再放一个静态控件用同样的绘制逻辑画下一个方块的 4×4 矩阵玩家就能提前规划。实现上只需要维护一个m_nNextTypeSpawnPiece时把当前类型换成m_nNextType再随机生成新的m_nNextType。第二个是「等级加速」。用一个m_nLevel记录等级每消 10 行升一级SetTimer的间隔从 500 毫秒逐步降到 100 毫秒。注意SetTimer对同一个 ID 再次调用会重置间隔不需要先KillTimer。但间隔别低于 80 毫秒否则键盘操作跟不上体验反而差。第三个是「暂停恢复」。暂停时KillTimer恢复时重新SetTimer。但要注意暂停期间方向键不应该响应所以在PreTranslateMessage里判断m_bPaused。另外暂停时画布上可以叠加一层半透明遮罩用AlphaBlend或者简单画个灰色矩形加文字。第四个是「最高分持久化」。用WritePrivateProfileString写到 ini 文件启动时GetPrivateProfileInt读回来。这是 MFC 里最轻的持久化方案不需要数据库。文件路径用GetModuleFileName取 exe 所在目录拼一个score.ini。// 保存最高分 CString strPath, strScore; GetModuleFileName(NULL, strPath.GetBuffer(MAX_PATH), MAX_PATH); strPath.ReleaseBuffer(); strPath strPath.Left(strPath.ReverseFind(\\) 1) _T(score.ini); strScore.Format(_T(%d), m_nHighScore); WritePrivateProfileString(_T(Game), _T(HighScore), strScore, strPath);这段代码先取 exe 全路径截掉文件名部分拼上score.ini。WritePrivateProfileString写入[Game]节下的HighScore键。读取时用GetPrivateProfileInt(_T(Game), _T(HighScore), 0, strPath)第三个参数是默认值文件不存在时返回 0。最后一个技巧是「用 WebView2 嵌 HTML 版做对比」。如果你已经有一个「完整 html 俄罗斯方块程序」可以把它嵌到 MFC 对话框里用 WebView2 控件加载本地 HTML。这样你能直观对比 GDI 绘制和 DOM 渲染的差异也能理解「html 全覆盖在 mfc 界面 webview2」这种混合方案的实际效果。不过 WebView2 需要额外安装运行时离线环境部署会麻烦一些纯 GDI 版本反而更省心。我自己做这个项目时最大的教训是别一上来就想着加特效、加音效、加网络排行榜。先把双缓冲和PreTranslateMessage这两个点吃透让方块能流畅地下落、旋转、消行剩下的都是锦上添花。我见过太多人卡在方向键没反应就放弃了其实就差一个PreTranslateMessage重写。把基础版本跑稳再逐步加功能这个对话框版俄罗斯方块能陪你练很久的 MFC 手感。希望帮到你。本文还有配套的精品资源点击获取