
简介这份资源是基于MFC对话框框架实现的经典俄罗斯方块游戏源码面向正在学习Windows GUI编程与C游戏开发的初学者及进阶者帮助读者理解如何把游戏逻辑嵌入对话框应用。压缩包共61个文件约19.92MB以h头文件与cpp源文件为核心另有obj、pdb等编译中间产物bmp位图、ico图标与rc资源脚本用于界面素材sln与vcproj工程文件可直接用Visual Studio打开并附有手把手制作教程文档。已有278人学习下载。代码中可看到CDialog派生类、OnInitDialog初始化、CStatic与CButton控件使用以及方块生成、旋转、下落与消行等算法实现配合定时器控制下落节奏。读者可据此掌握MFC消息映射与事件驱动机制并在此基础上扩展难度选择、音效等新功能是理解MFC框架与游戏逻辑结合的实践样本。1. 从一块会“卡住”的方块说起很多刚接触 Windows 桌面开发的同学第一个像样的练手项目往往不是计算器而是一个能跑起来的俄罗斯方块。原因很实在它把界面绘制、定时器、键盘响应、状态管理这几件事全串起来了做完一遍对话框程序的骨架就摸清了。这份资源是一个基于 MFC 对话框的俄罗斯方块实现没有引入任何第三方图形库全部靠 GDI 在对话框客户区里画方块、画网格、画分数。它适合两类人一类是想找一个能直接编译运行、看得见摸得着的 MFC 入门工程另一类是已经会写控制台逻辑但不知道怎么把游戏循环塞进 Windows 消息机制里的开发者。整个项目围绕一个主对话框展开逻辑集中、依赖干净拿来做二次开发或者课程设计都够用。2. 工程结构与技术选型一个对话框撑起整个游戏2.1 为什么是 MFC 对话框而不是文档视图MFC 提供了两种常见工程骨架单文档/多文档SDI/MDI和基于对话框Dialog Based。俄罗斯方块这种体量的程序用文档视图属于杀鸡用牛刀因为文档视图那套 Document/View/Frame 的拆分是为多视图、序列化、打印预览准备的而方块游戏根本不需要这些。基于对话框的工程只有一个主窗口类消息映射直接挂在对话框类上代码路径短调试时断点打下去一眼就能看到调用栈。常见做法是在 Visual Studio 里新建 MFC 应用选择“基于对话框”取消“关于”框和打印支持得到一个CXXXApp和一个CXXXDlg。游戏的所有逻辑都落在CXXXDlg里App 类基本不动。这样做的代价是窗口不能像文档视图那样自由拆分工具栏和状态栏但对一个方块游戏来说完全够用。2.2 目录与文件职责划分工程文件不多但每个文件的职责要分清否则后面改起来会乱。典型的文件清单如下文件职责TetrisApp.h/.cpp应用类负责启动和初始化TetrisDlg.h/.cpp主对话框承载游戏循环、绘制、输入Tetris.h/.cpp方块数据结构与碰撞、消行等纯逻辑resource.h、.rc对话框资源、控件 ID、图标stdafx.h/pch.h预编译头MFC 与 Windows 头文件把纯逻辑单独放进Tetris.h/.cpp是个好习惯。这样方块旋转、碰撞检测、消行这些不依赖窗口的代码可以单独测试也方便以后换渲染方式。很多新手把所有代码堆在 Dlg 里结果一个函数几百行改一个旋转规则要翻半天。2.3 游戏循环怎么塞进消息机制Windows 程序没有传统意义上的while(true)主循环界面刷新靠消息驱动。俄罗斯方块的“自动下落”本质是一个定时器在OnInitDialog里调用SetTimer每隔一段时间触发WM_TIMER在OnTimer里让当前方块下移一格然后重绘。BOOL CTetrisDlg::OnInitDialog() { CDialogEx::OnInitDialog(); // 初始化游戏数据 InitGame(); // 设置下落定时器ID 为 1间隔 500ms SetTimer(TIMER_DROP, 500, nullptr); return TRUE; } void CTetrisDlg::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent TIMER_DROP) { // 尝试下移失败则固定方块并生成新方块 if (!MoveCurrentBlock(0, 1)) { LockBlock(); ClearFullLines(); SpawnNewBlock(); } Invalidate(FALSE); // 触发重绘FALSE 表示不擦背景减少闪烁 } CDialogEx::OnTimer(nIDEvent); }SetTimer的第三个参数是间隔毫秒数500 表示每半秒下落一格难度提升时可以改成 300 甚至 200。Invalidate(FALSE)里的FALSE很关键它告诉系统不要先用背景色擦除客户区否则每帧都会闪一下。真正的绘制在OnPaint里完成定时器只负责改数据、发重绘请求职责分离。2.4 绘制双缓冲解决闪烁直接在OnPaint里用CDC画几十个方块快速刷新时会明显闪烁。标准解法是双缓冲先在内存 DC 上把整幅画面画完再一次性BitBlt到屏幕。void CTetrisDlg::OnPaint() { CPaintDC dc(this); CRect rc; GetClientRect(rc); // 内存 DC 与位图 CDC memDC; memDC.CreateCompatibleDC(dc); CBitmap bmp; bmp.CreateCompatibleBitmap(dc, rc.Width(), rc.Height()); CBitmap* pOld memDC.SelectObject(bmp); // 先填背景 memDC.FillSolidRect(rc, RGB(30, 30, 30)); // 画网格、已固定方块、当前方块、预览 DrawGrid(memDC); DrawBoard(memDC); DrawCurrentBlock(memDC); // 一次性贴到屏幕 dc.BitBlt(0, 0, rc.Width(), rc.Height(), memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOld); }CreateCompatibleDC创建的内存设备上下文和屏幕 DC 兼容CreateCompatibleBitmap创建同尺寸位图。SelectObject把位图选进内存 DC 后所有绘制都落在位图上最后BitBlt拷贝。注意退出前要把旧对象选回去否则位图对象释放时会出问题。这套写法在 MFC 里是通用套路换成画贪吃蛇、画五子棋都一样。3. 核心逻辑拆解方块数据、碰撞与消行3.1 方块的数据表示俄罗斯方块有 7 种基本形状I、O、T、S、Z、J、L每种有若干旋转态。最直观的表示是用一个二维数组描述每种形状的每个旋转态或者用 4x4 的位图矩阵。这里采用 4x4 矩阵因为旋转时只需要做矩阵转置加翻转代码短。// 每种方块用 4x4 矩阵表示1 表示实心格 static const int SHAPES[7][4][4] { // I { {0,0,0,0}, {1,1,1,1}, {0,0,0,0}, {0,0,0,0} }, // O { {0,0,0,0}, {0,1,1,0}, {0,1,1,0}, {0,0,0,0} }, // T { {0,0,0,0}, {0,1,0,0}, {1,1,1,0}, {0,0,0,0} }, // ... 其余 S、Z、J、L 同理 };用 4x4 而不是紧凑的 2x2 或 3x3是为了旋转时不用处理边界特判。代价是有些形状周围有空行空列绘制时要跳过。实际项目里也可以把每种形状的四个旋转态预先算好存成常量表运行时直接查表省去旋转计算这是性能上的常见优化。3.2 碰撞检测边界与堆叠方块能不能往某个方向移动取决于两件事是否超出左右下边界是否和已固定的方块重叠。棋盘用一个二维数组board[ROWS][COLS]表示0 为空非 0 为已占用。bool CTetrisDlg::CanPlace(int shape[4][4], int posX, int posY) { for (int r 0; r 4; r) { for (int c 0; c 4; c) { if (shape[r][c] 0) continue; int bx posX c; int by posY r; // 越界检查 if (bx 0 || bx COLS || by ROWS) return false; // 与已固定方块重叠 if (by 0 board[by][bx] ! 0) return false; } } return true; }posX、posY是当前方块 4x4 矩阵左上角在棋盘坐标系里的位置。by 0的判断是为了允许方块在顶部以上生成此时还没进入棋盘区域不算重叠。这个函数是移动、旋转、下落三个操作的公共判断入口任何操作前先调它返回 false 就拒绝。3.3 旋转的实现与“踢墙”旋转就是把 4x4 矩阵顺时针转 90 度新矩阵的[r][c]等于原矩阵的[3-c][r]。但旋转后可能撞墙或撞方块直接拒绝会让玩家觉得“转不动”。常见做法是旋转失败后尝试左右平移一格再转这叫简易踢墙。bool CTetrisDlg::RotateCurrent() { int rotated[4][4]; // 顺时针旋转 90 度 for (int r 0; r 4; r) for (int c 0; c 4; c) rotated[r][c] currentShape[3 - c][r]; // 原地尝试 if (CanPlace(rotated, curX, curY)) { /* 应用旋转 */ return true; } // 左移一格尝试 if (CanPlace(rotated, curX - 1, curY)) { /* 应用并左移 */ return true; } // 右移一格尝试 if (CanPlace(rotated, curX 1, curY)) { /* 应用并右移 */ return true; } return false; // 都失败保持原状 }这段逻辑看起来简单但实际手感差别很大。只做原地旋转贴墙时玩家会频繁转不动加上左右各一格尝试体验立刻顺滑。更完整的踢墙表会考虑上下偏移但对入门项目来说左右一格已经够用。3.4 消行与计分一行填满就消除上面的行整体下移。实现时从下往上扫描遇到满行就把该行以上的所有行复制到下一行然后顶部补空。int CTetrisDlg::ClearFullLines() { int cleared 0; for (int r ROWS - 1; r 0; --r) { bool full true; for (int c 0; c COLS; c) if (board[r][c] 0) { full false; break; } if (full) { // 上方所有行下移一行 for (int rr r; rr 0; --rr) memcpy(board[rr], board[rr - 1], sizeof(board[0])); memset(board[0], 0, sizeof(board[0])); cleared; r; // 同一行位置需要重新检查 } } score cleared * 100; // 简单计分每行 100 分 return cleared; }r那行容易漏掉消除一行后原来在r-1的行移到了r如果不重新检查当前位置连续满行会漏消。计分规则这里用最简单的每行 100 分实际可以按一次消 1/2/3/4 行给不同倍率这是二次开发时最容易改的地方。4. 编译运行与调试从源码到能玩的方块4.1 环境准备与工程打开这个工程依赖 Visual Studio 的 MFC 组件。安装 VS 时如果只勾了“使用 C 的桌面开发”MFC 可能没装上需要在安装器里补选“适用于最新 v143 生成工具的 C MFC”。打开工程后确认平台工具集和本机安装的版本一致不一致就在项目属性里改。编译前检查字符集设置。老工程常用多字节字符集新版本 VS 默认 Unicode如果资源文件里的中文出现乱码多半是这里不匹配。项目属性 → 高级 → 字符集按工程原本的设定选不要随意切换。4.2 关键编译错误与处理最常见的报错是找不到afxwin.h之类的 MFC 头文件这说明 MFC 组件没装或工程没链接 MFC。检查项目属性 → 常规 → 使用 MFC应设为“在共享 DLL 中使用 MFC”或“在静态库中使用 MFC”。另一个常见问题是stdafx.h与pch.h混用VS2019 之后新建工程默认pch.h老工程是stdafx.h统一改成一个即可。如果链接阶段报LNK2019找不到某个消息处理函数通常是消息映射表里声明了ON_BN_CLICKED或ON_WM_TIMER但对应的函数没实现或签名不对。OnTimer的签名必须是afx_msg void OnTimer(UINT_PTR nIDEvent)参数类型写错就会链接失败。4.3 运行时的观察点程序跑起来后重点看三件事方块是否按设定间隔下落、键盘左右下是否响应、消行后分数是否更新。如果方块不动先确认SetTimer的返回值非零再确认OnTimer里的事件 ID 匹配。如果按键没反应检查对话框是否设置了WS_TABSTOP抢焦点以及PreTranslateMessage里是否拦截了方向键。BOOL CTetrisDlg::PreTranslateMessage(MSG* pMsg) { if (pMsg-message WM_KEYDOWN) { switch (pMsg-wParam) { case VK_LEFT: MoveCurrentBlock(-1, 0); Invalidate(FALSE); return TRUE; case VK_RIGHT: MoveCurrentBlock(1, 0); Invalidate(FALSE); return TRUE; case VK_DOWN: MoveCurrentBlock(0, 1); Invalidate(FALSE); return TRUE; case VK_UP: RotateCurrent(); Invalidate(FALSE); return TRUE; } } return CDialogEx::PreTranslateMessage(pMsg); }在PreTranslateMessage里处理方向键返回TRUE表示消息已被消费不再往下传。这样做的原因是对话框默认会把方向键用于控件间焦点切换不拦截的话按键会被吃掉。这是 MFC 对话框游戏里非常典型的一个坑。5. 避坑与常见问题那些编译能过但玩起来不对的地方5.1 画面闪烁严重现象是方块每下落一格整个客户区闪一下。原因是在OnPaint里直接用CPaintDC逐格绘制且Invalidate默认带TRUE参数触发背景擦除。解决办法是改用双缓冲绘制并把所有Invalidate调用改成Invalidate(FALSE)。如果还有残留闪烁检查是否在OnEraseBkgnd里做了多余的重绘可以直接返回TRUE跳过默认背景擦除。5.2 方块贴边后转不动现象是方块靠左墙时按上键没反应。原因是旋转只做了原地判断旋转后的矩阵超出了左边界。解决是在RotateCurrent里加入左右各一格的偏移尝试也就是前面说的简易踢墙。如果加了还不行检查CanPlace里bx 0的判断是否写成了bx 0差一个等号就会把合法位置判成越界。5.3 消行后上方方块错位现象是消掉一行后上面的方块整体下移了但位置偏了一列或一行。原因多半是memcpy拷贝时长度用了sizeof(board)而不是sizeof(board[0])把整个棋盘拷进了单行。正确写法是memcpy(board[rr], board[rr-1], sizeof(board[0]))board[0]是一行的字节数。这个错误编译不报运行才暴露属于血泪经验级别的坑。5.4 定时器越开越多现象是游戏越玩越快或者暂停后恢复速度异常。原因是在某些分支里重复调用了SetTimer而没有先KillTimer同一个 ID 的定时器被反复设置。解决是约定一个定时器 ID设置前先KillTimer或者在OnInitDialog里只设一次暂停用KillTimer、恢复用SetTimer成对出现。5.5 高 DPI 下界面错位现象是在缩放 125% 或 150% 的显示器上方块和网格对不齐。原因是绘制坐标用了固定像素值没有按 DPI 缩放。简单处理是在程序清单里声明 DPI 感知或者用GetDpiForWindow拿到缩放比例后统一乘系数。入门项目可以先固定窗口大小、禁止调整规避这个问题。6. 二次开发把方块改成你自己的版本拿到这个工程后最值得先动的是难度曲线和计分规则因为这两处改动小、见效快能帮你快速建立对代码路径的信心。难度方面把SetTimer的间隔从固定 500ms 改成随等级递减比如每消 10 行升一级间隔减 30ms下限 150ms。实现上在ClearFullLines里判断score / 1000是否跨级跨级就KillTimer再SetTimer新间隔。void CTetrisDlg::UpdateLevel() { int newLevel score / 1000; if (newLevel ! level) { level newLevel; int interval max(150, 500 - level * 30); KillTimer(TIMER_DROP); SetTimer(TIMER_DROP, interval, nullptr); } }计分规则可以改成经典的四行倍率一次消 1 行 100 分2 行 300 分3 行 500 分4 行 800 分。改动点就在ClearFullLines返回后按cleared查表加分逻辑集中不容易改坏。再往深一层是加“下一个方块预览”和“暂存Hold”功能。预览需要在SpawnNewBlock时提前生成下一个形状存到成员变量绘制时在侧边小区域画出来。Hold 则是记录一个暂存形状按键时和当前方块交换交换后重置位置。这两个功能都会碰到同一个问题方块生成位置和棋盘坐标的换算建议先把坐标换算抽成一个CellToRect(row, col)函数所有绘制都走它改起来才不会到处漏。验证改动是否正确的办法很土但有效在LockBlock里加一句日志把当前棋盘每行的占用情况打到调试输出消行前后各打一次肉眼比对。我一般改完消行逻辑都会强制走一遍这个日志因为消行错位这种 bug 靠看画面很难定位日志一打就现原形。从那以后我每次动棋盘数组的拷贝逻辑都先把这段日志打开跑三局确认没有错位再关掉。希望这个工程能帮你把 MFC 对话框这套东西真正跑通、改顺。本文还有配套的精品资源点击获取