资讯详情

资讯详情

C++俄罗斯方块小游戏实现:从主循环到图形界面的完整指南

简介这份基于C实现的俄罗斯方块小游戏完整源码适合C初学者、游戏开发入门者以及需要参考小型项目结构的个人学习者。代码采用头文件与实现文件分离的组织方式涵盖矩阵运算、界面控制、游戏逻辑等模块并附带Visual Studio解决方案与项目配置便于直接编译运行和二次修改。资源包共含27个文件压缩后约230KB以10个h头文件、7个cpp源文件为主体辅以sln/vcxproj工程文件、rc资源脚本、ico图标及readme说明文档结构清晰能直观展示一个Windows桌面小游戏从资源定义到核心逻辑的完整脉络。已有691人学习下载适合用来理解C面向对象设计、消息映射和简单游戏循环的实现思路。读者可从中获得可直接构建运行的项目模板、较规范的代码分层方式以及配套的文档说明对上手MFC或Win32游戏编程有实际参考价值。1. 基于C的俄罗斯方块小游戏一晚上能写完但先想清这三个问题“基于C的俄罗斯方块小游戏设计源码”这类标题在 C 课程设计里几乎年年出现网上一搜一大把但大部分所谓“源码”要么编译不过要么只画了一个静态方块矩阵不能玩。我见过太多同学把作业交上来运行起来确实有个窗口但按方向键没反应消行逻辑还是错的——这种源码拿去答辩老师一眼就能看出是拼的。俄罗斯方块这个游戏代码量少说也要 400 到 600 行但它恰好覆盖了 C 初学者最该练的三件事二维数组的读写操作、游戏状态循环的组织方式、以及最基础的输入输出交互。无论你用控制台、Win32 窗口还是 EasyX 图形库核心逻辑都一样一个 10 列 20 行的场地上方块每帧下落一格玩家左右移动和旋转落定后消行。这篇文不教你背代码而是把一条能跑的路线拆开讲——先跑通最小版本再写出不翻车的方块逻辑再决定要不要上图形界面。文中每个代码块都是可编译的片段配上参数说明和踩坑记录。适合三类人做 C 课程设计的学生、想练 C 小游戏项目的入门者、以及想给期末作业加分的“自救型选手”。2. 先把最小可玩版跑起来控制台 20×10 数组 主循环2.1 主循环框架为什么你写不好一个不卡死的循环俄罗斯方块的本质是一个固定频率的循环每秒跑几帧每帧处理三件事读输入、更新方块位置、重绘画面。很多人第一次写游戏失败是因为把这三件事全塞进了一个 while 死循环里没有任何节奏控制结果方块下落速度跟 CPU 频率绑定i7 上快得看不见云端服务器上慢得像幻灯片。最小版本的控制台程序不需要并发也不需要用线程只要一个主循环加 Sleep 延时就够了。关键是把“下落”和“读键盘”分开下落按固定时间间隔触发键盘输入按帧立刻响应。下面这个循环是被验证过无数次的可靠结构#include iostream #include conio.h #include Windows.h using namespace std; int main() { const int COLS 10, ROWS 20; int board[ROWS][COLS] {0}; // 用二维数组存场地0 表示空 bool running true; DWORD lastDrop GetTickCount(); // 记录上一次下落的时间戳 const int SPEED_MS 800; // 每 800ms 自动下落一格 while (running) { // 先处理输入 if (_kbhit()) { // conio.h 的键盘检测函数 char key _getch(); if (key a) /* 左移 */ if (key d) /* 右移 */ if (key s) /* 加速下落 */ if (key w) /* 旋转 */ if (key 27) running false; // ESC 退出 } // 再按照固定节奏更新 DWORD now GetTickCount(); if (now - lastDrop SPEED_MS) { lastDrop now; /* 方块下落一格 碰撞检测 */ } } return 0; }这段代码的要点在于用GetTickCount()做时间戳判断而不是用Sleep(100)无脑阻塞主循环。后者的坏处是按键必须等 Sleep 结束才能被响应手感会明显迟钝。用时间戳的方式输入循环是尽量快的下落节奏单独计时这样按方向键不会有迟滞感。_kbhit()和_getch()是conio.h里的老牌函数控制台程序里读取单个按键就是它们最方便不需要清空缓冲区也不受换行符影响。注意一个细节GetTickCount()返回的是毫秒级的时间DWORD类型在 64 位系统上依然是 32 位无符号整数游戏跑不了那么久不会溢出可以放心用。2.2 方块怎么存一维数组就是二维数组的“降维打击”很多教程一开始就教用int tetromino[4][4]表示一块俄罗斯方块但这其实给新手挖了个大坑——旋转的时候你得处理各种空位填充最后代码变成一堆魔数。更可靠的做法是每种方块用一个固定长度的数组存储它的“相对坐标”配合一个中心点来实现旋转。常见做法是用 4×4 的布尔矩阵表示一个方块是有道理的——旋转可以当作矩阵转置加翻转。但由于方块只有 7 种形态任何一个形态都可以预先把旋转后的样子写进常量表运行期直接查表不用每次都做矩阵运算。查表比计算来得稳妥因为控制台版的碰撞检测往往是逐格判断查表不会引入浮点误差。我一般会把方块的数据结构写成这样struct Block { int shape[4][4]; // 4x4 矩阵1 表示有格 int color; // 控制台颜色编号图形界面时代用 int x, y; // 当前在场地中的位置左上角坐标 };数组里存 0 和 1 而不是存字符或坐标是为了碰撞检测方便——新位置只要看board[y dy][x dx]位置上是否为 1 就能判断能不能放。这里顺便说一句二维数组名传给函数时会退化为指向一维数组的指针如果面试考到“指针用法C”这就是最好的例子。你把int shape[4][4]作为函数参数传递时实质上传过去的是int (*)[4]所以函数里访问shape[i][j]等价于(*(shape i))[j]不用在函数签名里写二维边界。2.3 编译运行VS Code 和 Dev C 两条路线怎么选写完了核心结构得先过编译这一关。C 小游戏的编译环境选择直接影响新手的第一体验用 Visual Studio 建工程略重用 Dev C 自带编译器开箱即跑但调试体验一般。我推荐 VS Code 搭配 MinGW-w64因为环境配置一次以后能一直用而且命令行编译能让你清楚看到每一个编译步骤。VS Code 里配置 C/C 环境是比较琐碎的先装 C/C 扩展再下载 MinGW-w64 解压然后给系统 PATH 加上 bin 目录。检查配置是否完成在终端敲g --version如果能输出版本号说明 MinGW 没问题。那么编译上面的最小程序只需要一句话g tetris.cpp -o tetris.exe -stdc11 -static-libgcc -static-libstdc-static-libgcc和-static-libstdc这两个参数值得专门说明它们把运行库静态链接进 exe生成的程序换台没有装 MinGW 的电脑也能直接跑。很多同学交了源码到实验室电脑上一编译不过或者运行时提示缺 libstdc-6.dll就是因为没加这两个参数。用 Dev C 的话编译器配置里勾上“静态链接”效果一样。Windows 平台还有一个常见的坑是提示缺 VCRUNTIME 相关 dll实际上 Visual C Redistributable 没装装一个最新版就能解决这个在部署给他人用时属于必备知识。3. 写出不翻车的方块逻辑旋转、碰撞检测与消行怎么组织3.1 碰撞检测用“预模拟”而不是“落地后修正”碰撞检测是俄罗斯方块最容易翻车的地方也是最值得抠细节的地方。初学者最容易犯的错是先把方块坐标移动了再检查是否越界如果越界就“退回去”。这个思路在理论上没错但实现起来容易出边界错误比如方块已经穿到场地左边线外你回退一个格子却发现它卡在墙里因为回退方向判断错了。更可靠的做法是“预模拟”——在真正修改方块坐标之前先假设移动后的坐标检查这个位置是否合法合法才真正赋值不合法就什么都不动。这个方法对左移右移下落旋转全部适用代码量反而更少而且逻辑高度统一。bool canMove(const int board[20][10], const Block b, int dx, int dy) { for (int i 0; i 4; i) { for (int j 0; j 4; j) { if (b.shape[i][j] 1) { int newX b.x j dx; int newY b.y i dy; // 超过左右边界或底部直接非法 if (newX 0 || newX 10 || newY 20) return false; // 场地已有方块非法 if (newY 0 board[newY][newX] ! 0) return false; } } } return true; }这个函数的逻辑说明对 block 的 4×4 矩阵逐格扫描只对值为 1 的格子做判断。注意newY 0这个条件不能省——方块在场地顶部以上时y可能为负这时不能去访问board数组的负索引否则就是最经典的越界访问未定义行为程序不崩只是运气好。dx和dy是移动向量左移就是(-1, 0)右移是(1, 0)下落是(0, 1)旋转要特殊处理因为旋转后 matrix 本身会变。3.2 旋转逻辑查表法比旋转算法更适合新手旋转算法在纸面上很漂亮——转置再左右翻转S 型方块逆时针转7 种方块统一处理。但真把代码写出来很多人会栽在旋转中心偏移问题上转完发现方块的位置跟着跳了。尤其 T 型和 L 型这种不对称方块旋转中心不在方块自身的“中心格”上直接转就会整体偏移。我建议新手直接用查表法在程序里预先把七种方块的每一种旋转状态都存成常量数组。I 型需要 2 种状态O 型只有 1 种其余五种都需要 4 种状态一共 2 1 5×4 23 个 4×4 矩阵。量不大但省掉所有算法复杂度。// 以 T 型方块为例存储它的两个旋转状态实际需要存满 4 个 const int T_SHAPE[4][4][4] { { {0, 1, 0, 0}, {1, 1, 1, 0}, {0, 0, 0, 0}, {0, 0, 0, 0} }, { {0, 1, 0, 0}, {0, 1, 1, 0}, {0, 1, 0, 0}, {0, 0, 0, 0} } // 还有两个状态按同样格式补全即可 };用查表法之后旋转就变成一次数组下标切换currentRot (currentRot 1) % totalRotations。代价是程序员需要手动列出每个矩阵抄错一个数就是玄学 bug。我的习惯是先把 7 种方块画在纸上用网格纸标好坐标再誊进代码不要凭空在脑子里想。写完旋转后立刻做一次自测把四种旋转状态全部打印出来肉眼检查对称性和形状。3.3 消行与计分从下往上扫是性能和逻辑的双重优化消行的核心逻辑很简单检查每一行如果整行都是 1就删掉这一行上面的所有行整体下移一格。但这里的“整体下移”写不好就会翻车常见的问题是上面行覆盖了下面已经消掉的行。标准做法是从下往上扫而不是从上往下。从下往上的另一个好处是当我们消掉第 18 行后把 17 行及以上的内容整体往下拷贝不会影响我们已经扫过的区域。代码实现int clearLines(int board[20][10], int score) { int lines 0; for (int row 19; row 0; ) { // 从最底部往上遍历 bool full true; for (int col 0; col 10; col) { if (board[row][col] 0) { full false; break; } } if (full) { // 上面的行整体下移 for (int r row; r 0; r--) { memcpy(board[r], board[r - 1], sizeof(int) * 10); } memset(board[0], 0, sizeof(int) * 10); // 顶部置空 lines; score 100 * lines; // 连续消行加分递增 // 注意这里不要 row--因为当前行已被新行覆盖需要重新检查 } else { row--; } } return lines; }注意代码里的注释消行成功后row不自增也不自减而是继续检查当前位置——因为上面的行已经移下来了当前位置可能是满行也可能不是必须重新检查。这个细节是很多人的消行逻辑在连续消两行时只消一行的原因。memcpy在这里替换掉手写的 for 循环赋值效率更高也可能让新手困惑但这是 C 里对整块内存做快速拷贝的标准姿势。消行计分规则用 100、300、500、800 这种递增逻辑也值得做成常量表方便最后调难度平衡。3.4 随机方块别再用裸的 rand() 了C 随机数在游戏里是最容易被忽略的部分。很多初学代码长这样srand(time(NULL)); int type rand() % 7;这能满足基础需求但有两个问题第一rand()的质量一般分布没有保证第二每次程序运行间隔太短时time(NULL)返回的种子可能相同导致前几个方块完全一样。C11 标准库已经提供了random用mt19937配合uniform_int_distribution是目前最稳妥的方案#include random std::mt19937 rng(std::chrono::steady_clock::now().time_since_epoch().count()); std::uniform_int_distributionint dist(0, 6); int nextType dist(rng); // 生成 0~6 的方块类型这段代码用时间的高精度计数做种子即使两次启动间隔极短也不会一样。另外有个游戏设计的细节不要让连续 5 次以上出现同一种方块实践中可以加一个“回头修正”——如果连续同种超过 3 次就强制换一种。这个在后文进阶部分会再讲到。4. 图形界面不是必须的EasyX / Win32 / SFML 怎么选4.1 三种方案对比你的课程设计要求决定选型控制台版本的俄罗斯方块功能完整但视觉上毕竟简陋。大多数课程设计要求“图形界面”这时候你需要选一个 C 图形库。三个选项各有取舍我直接给一张对比表方案学习成本跨平台依赖适合场景Win32 API中高仅 Windows系统自带课程设计要“纯 API”时EasyX低仅 Windows需要下载安装最快做图形界面SFML中跨平台需要配置链接想学到可迁移的图形编程EasyX 是最省事的它就是一套封装好的绘图 API在 Visual Studio 里装好插件后initgraph(400, 600)一行就能开窗口rectangle()画方块格子不需要理解消息循环。Win32 API 则要求你理解窗口类、消息处理、WM_PAINT复杂度陡增但如果是 C 课程的硬性要求绕不开。SFML 更像工业选项配置略麻烦但它跨平台写出来的代码在 Linux 和 Windows 上都能编译。4.2 Win32 API 最小窗口理解消息循环比画格子更重要如果你必须用 Win32 API我建议先跑通一个什么都没有的空白窗口再往里面加绘图逻辑。很多人一上来就同时写窗口创建和游戏逻辑导致“窗口能弹出来但完全没响应”这种查不明原因的翻车。Win32 的最小程序骨架大致是这个结构#include Windows.h LRESULT CALLBACK WndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) { switch (msg) { case WM_KEYDOWN: if (wParam VK_LEFT) /* 左移 */ if (wParam VK_RIGHT) /* 右移 */ if (wParam VK_DOWN) /* 加速 */ if (wParam VK_UP) /* 旋转 */ return 0; case WM_PAINT: /* 在这里画场地和方块 */ return 0; case WM_DESTROY: PostQuitMessage(0); return 0; } return DefWindowProc(hWnd, msg, wParam, lParam); }这个回调函数里键盘事件通过WM_KEYDOWN和虚拟键码VK_LEFT来获取不再依赖控制台的_kbhit()。游戏逻辑照样用之前的时间戳循环驱动只是把“重绘画布”的部分换成InvalidateRect触发重绘。Win32 的核心难点在于理解消息机制是一个黑匣子你注册了窗口类系统才能把键盘事件分发到你的回调函数。如果窗口注册失败回调就永远不会被调用程序看起来像卡死了一样。抄代码时最容易漏的是窗口类名称不一致——注册时用的类名和创建窗口时用的类名不同结果回调永远接不到消息。4.3 EasyX 画格子一行代码切到图形模式如果你只是想让程序看起来“像游戏”EasyX 是最快的路。它的核心绘图逻辑非常直白每次重绘时先清屏再整个场地逐格涂颜色。#include graphics.h void drawBoard(const int board[20][10]) { cleardevice(); // 清空画布 setfillcolor(BLACK); fillrectangle(0, 0, 400, 600); // 画背景 for (int row 0; row 20; row) { for (int col 0; col 10; col) { if (board[row][col] ! 0) { setfillcolor(RGB(30, 144, 255)); // 有方块的格子涂蓝色 } else { setfillcolor(RGB(20, 20, 20)); // 空格子涂深灰 } // 每个格子 30x30 像素留 2 像素间距 fillrectangle(col * 30 1, row * 30 1, (col 1) * 30 - 1, (row 1) * 30 - 1); } } }这段代码的运行逻辑很简单每次游戏状态更新后调用drawBoard(board)把二维数组映射到 400×600 的窗口上。EasyX 的坐标系统是左上角原点往右是 x 递增往下是 y 递增——这和数组的下标方向天然一致board[row][col]对应(col * 30, row * 30)非常直观。注意一个性能问题fillrectangle是一格一格画每帧要把 200 个格子全部重绘这在 EasyX 下完全够用60 帧毫无压力。但如果用 Win32 纯 GDI 做同样的全量重绘窗口尺寸放大到全屏就会感觉到闪烁——这是后文避坑章节要处理的“双缓冲”问题。5. 避坑四个让俄罗斯方块源码翻车的高频问题5.1 方块跑到场地外面数组越界的隐蔽形态现象方块下落时有时候会穿到场地左边界外还能继续向下有时候直接程序崩溃报内存访问冲突。原因碰撞检测里的canMove函数只检查了右边界和左边界但忘了检查方块自身矩阵里有格子落在场地顶部的负索引区。在下落过程中如果方块尚未完全进入场地y i可能为负访问board[negative]就是未定义行为。控制台版奇怪的是偶尔不崩但方块显示位置错乱因为你在往数组前面的一段脏数据里寻找自己的方块。解决在canMove里加一行if (newY 0) return true;表示场地顶部的方块不需要和现有方块碰撞可以自由下落进入场地。这条规则要单独写在边界检查之前。我把它放在最开头防止负索引访问。5.2 按住方向键不动方块左一下右一下跳格现象按住右方向键方块没有持续移动而是只移动一格就停住或者按键感觉反应极其迟钝要按 200ms 才能移动第二个格子。原因控制台版的_kbhit()检测的是“当前有按键在缓冲区”但很多键盘的按键重复率受系统设置影响——按住一个键系统会先发出一次按下事件停顿几百毫秒再连续重复这个停顿期里_kbhit()返回 false。游戏循环发现没按键方块当然不动。解决不要依赖系统按键重复而是在自己的循环里做连续移动状态机。维护四个布尔变量moveLeft、moveRight、moveDown、rotatePressed按下一次键就置 true然后每次循环根据当前状态执行移动移动成功后维持状态直到检测到该键物理释放。具体做法可以用GetAsyncKeyState(VK_LEFT) 0x8000判断物理键持续按下但注意要加一个帧间隔限制——每 50ms 才移动一次防止速度过快。5.3 消行后场上出现了莫名其妙的空洞现象消掉满行后上面的方块往下掉了一部分但掉落后的行中间出现了空格再消一行时上面的方块覆盖了下面的方块。原因最常见的是从上往下遍历消行。假设第 18 行满了你把第 17 行拷贝到第 18 行然后继续往上遍历第 17 行。此时第 17 行可能还没处理满行状况但第 18 行的新内容被“用掉了”逻辑就乱了。另一个常见的错误是拷贝时用for (int r row; r 19; r)从上往下逐个拷贝导致相邻行互相覆盖。解决严格从下往上、对每个可能满的位置逐行检查当前行消完后不对row做任何自增自减留在原位置重新检查。这个在上文clearLines函数里已经写明了。记住一个口诀拷贝永远是把上面的内容往下面覆盖上面不会凭空消失所以从下往上绝对不会丢数据。5.4 每次运行掉落的方块顺序一模一样现象第一块永远是 I 型第二块永远是 T 型换台电脑也一样整套序列可以背下来。原因rand()系统库默认用固定的种子初始化srand()要么没调要么调用方式参数恒定。这个问题在很多初学 C 小游戏里都会被忽略因为单次运行根本看不出问题。解决按前面章节的方案改用random库的mt19937以时间计数做种子。如果非要用rand()至少在main()开头写srand(static_castunsigned(time(nullptr)))并包含ctime头文件。最常见的翻车是忘在main里调用srand只声明了函数没执行。5.5 游戏画面闪烁得像打雷现象图形界面版本运行起来窗口里的画面一直在闪尤其是方块下落的时候一块一块闪眼睛受不了。原因这是最经典的 GDI 双重绘制问题——每次WM_PAINT响应时窗口背景先被擦成白色再填充黑色背景再画格子这个过程肉眼可见就产生了“闪烁”。EasyX 默认有双缓冲问题不大Win32 纯 GDI 就必须手动处理。解决在窗口类注册时用hbrBackground (HBRUSH)GetStockObject(BLACK_BRUSH)将背景刷设为黑色这样背景擦除本身和游戏背景色一致视觉上就不闪。更专业的做法是内存 DC 双缓冲创建一个内存位图把绘制全部画在位图上最后一次性BitBlt到窗口上。一个万能思路是“所有局部绘制先画到隐藏画布最后整块贴图”这个不只在俄罗斯方块里管用任何 2D 小游戏都适用。6. 落地验证与继续折腾把源码当“最小可测试系统”来玩当你的俄罗斯方块能玩了真正的学习才刚刚开始。我给你的第一个建议是不要急着加音效、加背景图先把难度曲线做出来。难度曲线的基础是一个“速度随分数变化”的规则比如每消 10 行下落间隔从 800ms 减少 50ms最低到 200ms。这个规则用一行代码就能实现int speed max(200, 800 - (score / 1000) * 50);max保证速度有下限不会快到没法玩。这个公式可以作为课程设计答辩里的“亮点”讲老师最想看到的是你自己对游戏平衡性的思考而不是界面贴图。第二个建议是用“行为测试法”验证核心逻辑是否可靠而不是靠肉眼盯着看两局。做法是写一个测试脚本调用你的核心函数反复摆出“全行待消”的状态看消行后数组是否符合预期。这一步不需要图形界面纯粹在 main 函数里暴力赋值再调用clearLines输出结果对比预期。当你把“旋转、碰撞、消行”做成独立函数时这个测试就特别顺手——反过来逼你把代码写得职责分明这就是软件工程的味道了。第三个建议是关于代码组织的。我见过太多课程设计源码把 600 行全写在 main 函数里答辩时老师随手问你“游戏状态机是怎么实现的”你答不上来留下一个“拼代码”的印象。如果你至少把代码按四个文件拆开——main.cpp、game.cpp、tetris.h、block.cpp——这种结构本身就在替你说话。在代码里引用const常量表而不是到处写魔数用的是static局部变量保存决策状态这些细节都能在答辩时被老师抓到加分点。很多人背了一堆 C final、static、const 的八股题不如在这些源码细节里体现一次。我自己的习惯是控制台版和图形版各留一份控制台版当逻辑参考图形版当成果展示。调试逻辑永远用控制台版因为 printf 一行比任何打断点都快确认逻辑没问题再同步到图形界面。这能帮你省掉大量时间花在“找 bug 却在窗口绘制代码里打转”的痛苦。希望你第一次跑自己的俄罗斯方块源码时能比我当年的手感顺畅得多。希望帮到你。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →