C++俄罗斯方块源码解析:从MFC工程到双缓冲游戏开发实践
简介这是基于C实现的俄罗斯方块小游戏源码面向C初学者和游戏开发入门的个人学习者通过一个可运行的经典小游戏展示窗口程序的基本架构与游戏循环设计。压缩包共27个文件大小约230KB其中10个头文件用于类声明与接口定义7个C源文件实现核心逻辑并辅以Visual Studio解决方案文件、项目配置文件、资源脚本和说明文档便于直接打开工程查看结构、编译运行。已有691人学习下载适合结合代码阅读理解矩阵变换、方块旋转、碰撞检测与绘制刷新等重点逻辑。整套源码模块划分清楚带有若干辅助类和公共接口既可作为学习C项目组织的范例也能在此基础上增加音效、计分或关卡功能用于个人练习与二次开发。1. 一套能跑起来的 C 俄罗斯方块源码值得花半小时拆一遍俄罗斯方块大概是 C 学习者最想亲手写的小游戏之一但真动手写过的都知道把七种方块旋转对、消行不出 bug、画面不闪够折腾一个周末。这套 C 俄罗斯方块小游戏源码是一份完整的 Visual Studio 工程基于 MFC 框架25 个文件把游戏逻辑、矩阵运算、双缓冲绘图拆成了独立模块不是那种几百行代码堆在一个 cpp 里的玩具工程。它适合正在学 C 面向对象、想接触 Windows 界面编程的人也适合课程设计需要一套能运行、可讲解的参考项目。我建议你拿到手先别急着按 F5花十分钟把工程配置和文件结构过一遍后面调试会省大量时间。下面按我拆包时的顺序来讲照着走一遍你也能把它跑起来、改明白。2. 工程结构拆解25 个文件里哪些是核心哪些可以不动MFC 工程和纯控制台程序最大的区别是一上来就有 sln、vcxproj、rc 这一堆工程文件。很多第一次接触的人被这堆文件劝退实际上真正需要读的只有五六份。这一章先把文件按用途分好类再告诉你怎么快速确认工程能编译、代码从哪里读起。2.1 从 sln 到 vcxproj先确认工具集再谈运行Teris.sln 是 Visual Studio 的解决方案文件它本身不包含代码只记录这个解决方案下面挂了哪些工程。真正决定编译行为的是 Teris.vcxproj里面写着平台工具集PlatformToolset、字符集CharacterSet、是否使用 MFC 等关键配置。我拿到工程的第一件事是用文本方式直接搜 vcxproj 里的配置grep -E PlatformToolset|CharacterSet|UseOfMfc Teris.vcxproj如果看到 v143说明工程是 VS 2022 创建的v142 对应 VS 2019。缺工具集时 VS 会弹窗提示需要升级点了之后大部分情况能直接编译过但有些老工程会因为它重排资源文件导致 diff 变大所以我一般先记下原始配置再动手。CharacterSet 决定工程走宽字符还是多字节MFC 工程建议保持 Unicode老代码如果跑多字节会冒出一串 C4996。整套文件按用途可以归档成这张表类别文件说明入口与配置Teris.sln、Teris.vcxproj解决方案与工程配置主程序Teris.cpp / Teris.h窗口、消息处理、游戏流程游戏逻辑GameClassLib.h / .cpp方块数据、消行、状态管理基础封装CmyMatrix.h / .cpp自定义二维矩阵类绘图封装CmyMemDC.h / .cpp双缓冲内存画布交互模块CControlReg.h / .cpp、CDlgChinaWillWin.h / .cpp按键注册、游戏结果对话框资源与辅助Teris.rc、resource.h、res、pch.h、targetver.h图标菜单、预编译头等归档之后读代码就轻松了。我个人的阅读顺序是先确认环境再打开 Teris.cpp 找到 OnInitDialog 和 OnTimer看定时器怎么驱动 GameClassLib等到需要改界面外观时才去碰 Teris.rc 和资源文件。这样第一遍读只需要接触四五个文件不会被辅助模块干扰。2.2 逻辑与界面分离GameClassLib 替主程序扛掉脏活这套源码值得学习的一点是 GameClassLib 把游戏逻辑从窗口类里剥了出来。Teris.cpp 负责响应 WM_TIMER、WM_PAINT、WM_KEYDOWN 这些消息而方块能不能放、哪一行消掉了逻辑都封装在 GameClassLib 里。好处是调试时不用在消息循环里追逻辑坏处是第一次读的人会找不到入口。MFC 的消息循环对新手来说像个黑匣子你找不到传统 C 语言里那种 while(1) 游戏循环。窗口类里没有主循环Windows 在后台派发消息你的代码只是被动响应。要让游戏动起来就得有一个周期性的消息源也就是定时器。SetTimer 每隔固定毫秒触发一次 WM_TIMEROnTimer 里执行一帧逻辑这就是源码的节奏核心。理解这一点之后再看旋转和消行代码就不会迷路。2.3 CmyMatrix 与 CControlReg封装层解决什么问题CmyMatrix 是自定义的矩阵类游戏面板和单个方块都可以用它表示。有人会问为什么不直接用 vectorvector 个人项目确实可以但课程设计或笔试场景里自己实现一个带边界检查的矩阵类能同时展示运算符重载、深拷贝、RAII 这几个 C 核心概念。CmyMatrix 里通常重载 operator()用 board(y, x) 而不是 board[y][x]语义上更接近按坐标访问越界时也方便统一拦截。CControlReg 负责控制注册把按键输入映射到具体动作常见做法是维护一张键值到动作的映射表按下方向键就向游戏逻辑发旋转、左移、右移动作。好处是以后想改键位只动这张表不用翻所有消息处理函数。pch.h 是预编译头MFC 的 afxwin.h 等头文件展开很慢预编译头把这一层结果缓存下来之后编译就快了。如果以后把 GameClassLib.cpp 单独拷到别的工程记得带上 MFC 头文件和预处理宏否则会报一堆 afx 开头的未定义错误。3. 核心玩法实现方块定义、旋转、碰撞与消行这一章从 GameClassLib 的角度把玩法拆成三个问题方块长什么样、能不能动、消完行怎么处理。三个问题对应三段代码是这套源码里最值得抄的部分。3.1 方块定义用三维数组初始化七种 Tetromino七种标准方块的形状固定代码里最常见的写法是一个三维静态数组第一维表示 7 种方块后两维是 4x4 矩阵1 代表有格子0 代表空白。这样写的最大好处是数据和逻辑分离以后想加新形状往数组里加一项就行。// 七种标准方块I O T S Z J L统一用 4x4 矩阵表示 static const int TETRIS_BLOCKS[7][4][4] { { {0,0,0,0}, {1,1,1,1}, {0,0,0,0}, {0,0,0,0} }, // I { {0,0,0,0}, {0,1,1,0}, {0,1,1,0}, {0,0,0,0} }, // O { {0,0,0,0}, {0,1,0,0}, {1,1,1,0}, {0,0,0,0} }, // T { {0,0,0,0}, {0,1,1,0}, {1,1,0,0}, {0,0,0,0} }, // S { {0,0,0,0}, {1,1,0,0}, {0,1,1,0}, {0,0,0,0} }, // Z { {0,0,0,0}, {1,0,0,0}, {1,1,1,0}, {0,0,0,0} }, // J { {0,0,0,0}, {0,0,1,0}, {1,1,1,0}, {0,0,0,0} } // L }; int RandomBlockIndex() { // 用 mt19937 而不是 rand() % 7小范围随机分布更均匀 static std::mt19937 rng(std::random_device{}()); std::uniform_int_distributionint dist(0, 6); return dist(rng); }这段代码里有几个点值得说。第一rng 是静态局部变量首次调用初始化一次避免每次取方块都重新播种第二用 mt19937 替代 rand()是因为 rand() 低位的随机性差连续取 0 到 6 这类小范围数字时能明显感到规律课程设计里老师问起来这是个加分项第三方块统一用 4x4 而不是按形状用 3x3、2x2牺牲少量内存换来旋转算法统一。C 里静态数组的初始化规则字符串数组、多维数组一通百通在这里正好是现成的复习素材。3.2 旋转与碰撞矩阵转置加边界检查顺序不能乱旋转的本质是矩阵变换。顺时针旋转 90 度等价于把第 i 行第 j 列的元素放到第 j 行第 3-i 列写成代码只有几行void RotateBlock(int block[4][4]) { int tmp[4][4] { 0 }; for (int i 0; i 4; i) for (int j 0; j 4; j) tmp[j][3 - i] block[i][j]; // 顺时针旋转 90 度 memcpy(block, tmp, sizeof(tmp)); }注意这里不能原地旋转必须先写到临时矩阵再整体拷回去。原地旋转会覆盖后面还没读到的元素这是新手最容易翻车的地方看起来很简单的转置一写成原地算法就错。顺带一提函数参数写成 int block[4][4]数组名退化后本质是连续内存块这是 C 里二维数组传参的经典指针语义。碰撞检测比旋转更关键旋转后的方块不能穿墙也不能压到已经固定的格子bool CanPlace(const CmyMatrix board, const int block[4][4], int bx, int by) { for (int i 0; i 4; i) { for (int j 0; j 4; j) { if (!block[i][j]) continue; // 空白格不参与碰撞 int x bx j, y by i; if (x 0 || x BOARD_COLS) return false; // 左右墙 if (y BOARD_ROWS) return false; // 底部 if (y 0 board(y, x)) return false; // 撞到已固定块 } } return true; }参数 bx、by 是方块左上角在面板上的坐标block 里的相对坐标要加上这个偏移才是绝对坐标。判断顺序不能乱先跳过空白格再查边界最后查占用因为访问 board 之前必须保证下标合法。这段代码基本就是所有同类游戏碰撞检测的标准骨架背下来不亏。3.3 消行判定从下往上扫消一行翻一行消行算法很直观但实现有讲究。常见错误是从上往下扫消掉一行后上方行下移结果下面还有满行没检查到。正确做法是从面板底部往上扫发现满行就整体下移然后继续检查当前这一行因为下移之后这一行换成了新内容int ClearFullLines(int board[BOARD_ROWS][BOARD_COLS]) { int cleared 0; for (int y BOARD_ROWS - 1; y 0; ) { // 从底部往上 bool full true; for (int x 0; x BOARD_COLS; x) { if (board[y][x] 0) { full false; break; } } if (full) { for (int yy y; yy 0; yy--) memcpy(board[yy], board[yy - 1], sizeof(board[yy])); memset(board[0], 0, sizeof(board[0])); cleared; continue; // 消除后 y 行被新内容填充必须重新检查 } y--; } return cleared; }continue 是关键。一次消多行时第一轮消掉的行被上方内容填上如果不重新检查就会漏掉原本就是满行的下一行。返回值交给主流程计分单消、双消、四消对应不同分数这是后续扩展最容易接耦合的地方。主循环每帧流程是尝试下落一格能放就放不能放就把当前方块固定到面板、消行计分、生成新方块如果新方块生成位置立刻碰撞游戏结束。这一整套流程在 GameClassLib 里是闭合的窗口类只管调。4. MFC 绘图与双缓冲CmyMemDC 怎么把闪屏按下去MFC 程序跑起来容易画面不闪难。俄罗斯方块每次刷新要重画二十多行格子直接在 OnPaint 里往窗口画闪烁是肉眼可见的。源码里的 CmyMemDC 封装了一套标准双缓冲方案值得单独拆开讲。4.1 闪屏的来源擦背景和画前景在抢画布Windows 绘制窗口时系统先发 WM_ERASEBKGND 把客户区刷成背景色紧接着 WM_PAINT 再画实际内容。两次操作间隔稍大人眼就捕捉到先变白再变画面的闪烁。MFC 默认会把背景刷成白色所以不处理 WM_ERASEBKGND 的工程画面一定拼命闪。解决办法分两步一是让 WM_ERASEBKGND 直接返回 TRUE告诉系统背景不用你擦二是在 OnPaint 里先把整帧画到内存画布再一次贴回窗口。两步缺一不可只做第一步会留下残影只做第二步还是闪。4.2 CmyMemDC 封装构造时建画布析构时整体贴回双缓冲的核心思路很简单不直接往窗口 DC 上画而是先创建一个与窗口兼容的内存位图把所有格子画到位图上最后用一次 BitBlt 把整块位图复制到窗口。关键在于时机控制CmyMemDC 用 RAII 把这个时机封装得非常干净class CmyMemDC : public CDC { public: CmyMemDC(CDC* pDC, const CRect rc) : CDC() { CreateCompatibleDC(pDC); m_pDC pDC; m_rc rc; m_bitmap.CreateCompatibleBitmap(pDC, rc.Width(), rc.Height()); m_pOldBitmap SelectObject(m_bitmap); // 记录旧位图析构时还原 } ~CmyMemDC() { // 内存画布整块贴回窗口 DC只触发一次绘制 m_pDC-BitBlt(m_rc.left, m_rc.top, m_rc.Width(), m_rc.Height(), this, 0, 0, SRCCOPY); SelectObject(m_pOldBitmap); } private: CDC* m_pDC; // 保存窗口 DC析构时使用 CBitmap m_bitmap; CBitmap* m_pOldBitmap; CRect m_rc; }; void CTerisDlg::OnPaint() { CPaintDC dc(this); CRect rc; GetClientRect(rc); CmyMemDC memDC(dc, rc); // 内存画布创建完毕 memDC.FillSolidRect(rc, RGB(30, 30, 30)); // 底色画到内存 DrawBoard(memDC); // 已固定的格子 DrawCurrentBlock(memDC); // 当前下落的方块 } // memDC 析构一次 BitBlt 完成所有绘制这个类最值得学的就是析构函数里的收尾。OnPaint 里所有画布操作都发生在内存 DC 上函数退出时析构自动触发 BitBlt窗口只被重绘一次。SelectObject 返回的旧位图指针在析构时还原避免 GDI 资源泄漏这是 MFC 里特别容易漏的一步。绘图代码集中在 DrawBoard 和 DrawCurrentBlock改颜色、改格子大小都在这两个函数内完成不用动窗口类。4.3 刷新策略能局部失效就不要整窗重绘双缓冲解决了闪但没解决性能浪费。每次下落只影响方块那几格整窗 InvalidateRect 等于把面板全部重画一遍。更精细的做法是只让变化的矩形区域失效CRect rcBlock(curX * CELL_SIZE, curY * CELL_SIZE, (curX 4) * CELL_SIZE, (curY 4) * CELL_SIZE); InvalidateRect(rcBlock, FALSE); // FALSE 表示不擦背景第二个参数传 FALSE 很关键它告诉 MFC 不要发送 WM_ERASEBKGND。配合 CmyMemDC 的双缓冲画面既不会闪也不会有残影。注意 CELL_SIZE 是格子的像素边长curX、curY 是方块所在的行列坐标换算成像素后才是客户区坐标。判空、定位都在逻辑层做绘图层只负责把逻辑坐标映射成像素坐标这就是第 2 章说到的分离带来的直接收益。5. 编译避坑五个常见问题从工具集到字符集这套源码本身能编译但换到不同版本的 Visual Studio、不同语言环境的机器上会遇到一些典型问题。下面五条是我处理同类 MFC 工程时最常见的每条按现象、原因、解决三步整理。5.1 现象打开 Teris.sln 提示需要升级或工具集未安装原因是 vcxproj 里声明的 PlatformToolset 在当前 VS 里不存在或 MFC 组件没装。VS 2019 打开 v143 工程会提示升级到 v142直接确定通常能编译过但资源文件可能被重新排版git diff 会变大。解决办法是先按 2.1 的方式查工具集版本再决定升级工程还是装组件。个人学习场景我建议直接升级然后用 VS Installer 补装适用于最新 v143 生成工具的 C MFC。不想动工程文件的话在属性页手动把平台工具集改成当前版本效果一样。5.2 现象编译报错 C4996提示函数或变量不安全原因是 MFC 工程默认开启安全检查老代码里的 fopen、sprintf、strcpy 会被标记为不推荐使用。最小改动是在项目属性 → C/C → 预处理器 → 预处理器定义里加 _CRT_SECURE_NO_WARNINGS。但我更建议把报错的几个调用点改成 sprintf_s、strcpy_s 这类安全版本改动量很小换到任何编译器都不再报警告。C4996 本质是编译器的友好提示不是语法错误但课程设计评审通常要求零警告顺手改掉最省心。5.3 现象中文注释变成乱码或编译时报 C4819原因是 C4819 表示文件里有无法在当前代码页表示的字符。老工程的中文注释很多是 GBK 编码在 UTF-8 语言环境的系统上编译时编译器读不懂编码轻则警告重则把多字节字符解析成错误 token 导致编译失败。解决方法是把源文件另存为 UTF-8 with BOM同时确认 vcxproj 里的 CharacterSet 是 Unicode。只改文件编码不够还要字符集配置一致因为 MFC 的宽字符宏 _T 依赖这个设置。5.4 现象编译资源时报错找不到 res\Teris.ico 或 .rc 打不开原因是 Teris.rc 通过相对路径引用 res 目录下的图标工程文件夹整体移动过或者 .rc 被别的工具打开过之后路径失效。另外 .aps 是资源编译的缓存文件它和 .rc 不同步时会出现各种诡异报错。解决方法是先确认 res 文件夹确实在工程目录下且文件名一致然后删掉 Teris.aps重新编译让资源编译器从 .rc 重新生成。删 .aps 是 MFC 资源问题的后悔药绝大多数缓存不同步的报错都能靠它解决。5.5 现象用 Dev-C 或 MinGW 打开这个工程连解决方案都读不出来原因是 Teris.sln 和 Teris.vcxproj 是 Visual Studio 专有格式MinGW 系工具链只认 Makefile 或 CMakeLists。很多人用 Dev-C 练 C遇到 MFC 工程想拖进去编译直接翻车。解决方法是这类工程必须用 Visual Studio 打开Community 版就够。如果你只想研究游戏逻辑部分可以把 GameClassLib.cpp 和 CmyMatrix.cpp 抽出来新建一个控制台工程跑通逻辑层界面层和平台绑定没必要硬迁。这也是为什么源码强调逻辑与界面分离抽离成本越低复用度越高。6. 加一个暂停键最少改动与最快验证拿到源码以后最值得做的练手是加暂停功能。它不需要改游戏逻辑只涉及一个状态开关却能把 MFC 的消息处理、定时器、刷新机制全串起来适合作为读源码后的第一个动手实验。6.1 三处改动完成暂停第一处在类里加一个状态位第二处响应键盘第三处在定时器里判断状态// CTerisDlg 头文件里加一个成员变量 BOOL m_bPaused FALSE; void CTerisDlg::OnKeyDown(UINT nChar, UINT nRepCnt, UINT nFlags) { if (nChar P) { m_bPaused !m_bPaused; InvalidateRect(NULL, FALSE); // 刷新一次显示暂停状态 } CDialogEx::OnKeyDown(nChar, nRepCnt, nFlags); } void CTerisDlg::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent GAME_TIMER_ID !m_bPaused) GameTick(); // 暂停时不走游戏逻辑 CDialogEx::OnTimer(nIDEvent); }定时器还在走只是 GameTick 被挡在门外。这是刻意为之如果直接 KillTimer恢复时得重建定时器、处理剩余时间多一层逻辑就多一个出错点。用布尔开关挡逻辑暂停和恢复都不会打断定时器本身的节奏。6.2 验证改动断点比肉眼靠谱验证方法很简单在 GameTick 调用处下一行断点运行后按 P 暂停观察断点是否还命中。没暂停时定时器每次触发都进断点暂停后不再命中说明逻辑层确实被挡掉了再按 P 恢复断点重新命中游戏继续。这一步比直接看画面动不动可靠得多因为画面刷新和逻辑下落是两个独立节奏画面静止不代表逻辑没在跑。从那以后我每次拿到 C 小游戏源码第一件事永远是先编译通过再在定时器回调里下一个断点确认主循环位置最后才去读具体算法。这个顺序帮我避开了大量翻车现场希望你这次拆这份俄罗斯方块源码也能用上。希望帮到你。本文还有配套的精品资源点击获取