C/C++图形编程入门:使用EasyX图形库实现俄罗斯方块游戏

发布时间:2026/8/3 18:51:53
C/C++图形编程入门:使用EasyX图形库实现俄罗斯方块游戏 1. 项目概述为什么用EasyX做俄罗斯方块是个好主意最近在整理一些C/C的图形编程教学案例发现很多初学者在学完语法后面对的第一个“实战”项目往往就是俄罗斯方块。这个游戏逻辑清晰、体量适中堪称图形化编程的“Hello World”。但传统的做法比如用控制台字符画或者直接调用Windows API要么效果太简陋要么门槛太高很容易劝退新手。这时候EasyX这个图形库的优势就体现出来了。EasyX是一个为C/C语言设计的简易图形库它封装了Windows的GDI绘图接口提供了一套类似Turbo C里graphics.h的简单函数。你不需要理解复杂的窗口消息机制也不用去折腾像素格式和双缓冲用几个像initgraph初始化图形窗口、circle画圆、outtextxy输出文字这样的函数就能快速在屏幕上画出图形。对于俄罗斯方块这种以矩形方块为核心元素的游戏来说简直是量身定做。你只需要关心方块的坐标、颜色和旋转逻辑剩下的绘图工作EasyX几行代码就能搞定。这个项目适合谁呢首先是刚学完C/C基础语法想找个项目练手、巩固知识点的同学。通过这个项目你能把数组、结构体、循环、条件判断、函数封装等知识点串起来用。其次是对游戏开发感兴趣但被DirectX或OpenGL的复杂度吓到的入门者。用EasyX实现俄罗斯方块能让你快速建立起游戏循环、状态更新、图形渲染的基本概念获得正反馈。最后哪怕你是个有经验的开发者想快速给小朋友或学生演示一个可玩的图形程序这也是个绝佳的选择因为从零到可玩可能只需要一两个小时的编码时间。2. 核心设计思路与数据结构拆解做游戏最忌讳一上来就埋头写代码。我们先得把俄罗斯方块这个游戏“拆解”成计算机能理解和处理的数据与逻辑。核心思路可以概括为用数据表示状态用逻辑驱动变化用图形渲染结果。2.1 游戏世界的两大核心数据结构俄罗斯方块的世界主要由两部分构成下落的当前方块和已经堆积在底部的静态方块。我们需要为它们设计合适的数据结构。当前方块Tetromino我们用一个结构体来表示它再合适不过了。一个方块有类型I, J, L, O, S, T, Z 七种、有颜色、有在游戏区域内的坐标通常是左上角或中心点作为参考点还有它的旋转状态。// 定义七种方块类型 #define SHAPE_I 0 #define SHAPE_J 1 #define SHAPE_L 2 #define SHAPE_O 3 #define SHAPE_S 4 #define SHAPE_T 5 #define SHAPE_Z 6 // 当前方块结构体 struct CurrentBlock { int type; // 方块类型0-6 int rotation; // 旋转状态0-3 int x, y; // 方块在游戏区域网格中的坐标通常指某个参考点如中心或左上角 COLORREF color; // 方块颜色EasyX中使用RGB宏定义如RGB(0, 255, 255)表示青色 };这里有个关键点方块的形状数据如何存储最经典的方法是使用一个三维数组blocks[7][4][4][4]。第一维是7种类型第二维是4种旋转状态第三、四维是一个4x4的布尔矩阵1表示该格子有方块0表示空白。例如blocks[SHAPE_I][0]就代表了I型方块在0度旋转时的形状矩阵。这种预定义的方式在判断碰撞和绘制时非常高效。游戏地图Board用来存储已经落到底部并固定下来的方块。我们可以用一个二维数组来表示比如int map[HEIGHT][WIDTH]。HEIGHT和WIDTH是游戏区域的高和宽以方块为单位。数组的每个元素存储一个颜色值COLORREF如果为0或某个特定值如EMPTY则表示该格子为空否则就表示该格子被某种颜色的方块占据。#define BOARD_WIDTH 10 #define BOARD_HEIGHT 20 #define EMPTY 0 COLORREF gameBoard[BOARD_HEIGHT][BOARD_WIDTH] {EMPTY}; // 初始化全为空注意数组的行第一个下标通常对应垂直方向的Y坐标列对应水平方向的X坐标。并且在屏幕坐标系中Y轴是向下增长的这与我们习惯的数学坐标系相反。在定义gameBoard时gameBoard[row][col]中row的增加意味着向下移动col的增加意味着向右移动。这一点在后续坐标转换时要时刻牢记是很多新手bug的来源。2.2 游戏主循环与状态机任何游戏的核心都是一个无限循环即游戏主循环。在每一次循环中程序按顺序做以下几件事处理输入检测用户的键盘按键上、下、左、右、空格等。更新逻辑根据输入和游戏规则更新游戏状态。例如让当前方块自动下落一格判断是否触底是否需要生成新方块是否消行等。渲染输出将最新的游戏状态地图和当前方块绘制到屏幕上。这个循环必须足够快通常要达到每秒60帧FPS游戏看起来才会流畅。这意味着一次循环的时间要控制在16毫秒左右。在EasyX中我们可以用while (true)配合Sleep()函数来控制帧率但更推荐使用peekmessage或定时器来驱动这样能更精确地控制逻辑更新比如方块下落的频率而不受渲染帧率的影响。游戏本身可以看作一个状态机。基本状态包括PLAYING游戏中、PAUSED暂停、GAME_OVER游戏结束。通过一个状态变量在不同状态间切换可以控制主循环内哪些逻辑需要执行。例如在PAUSED状态只渲染画面和检测“解除暂停”的输入而不更新方块下落逻辑。3. 关键功能模块的详细实现有了清晰的数据结构和设计思路我们就可以动手实现各个功能模块了。这是从图纸到实物的过程每一步都需要仔细考量。3.1 图形初始化与坐标映射首先我们需要用EasyX创建一个图形窗口。#include graphics.h // EasyX图形库头文件 #include conio.h // 用于_getch()等键盘输入 int main() { // 初始化图形窗口宽度800像素高度600像素 initgraph(800, 600); // 设置窗口标题 SetWindowText(GetHWnd(), EasyX俄罗斯方块); // 主游戏逻辑... closegraph(); // 关闭图形窗口 return 0; }接下来是坐标映射这是连接逻辑坐标网格和物理坐标像素的桥梁。假设每个方块的大小是BLOCK_SIZE 30像素游戏区域从屏幕坐标(START_X, START_Y)开始绘制。那么逻辑坐标(gridX, gridY)对应的屏幕左上角像素坐标就是pixelX START_X gridX * BLOCK_SIZEpixelY START_Y gridY * BLOCK_SIZE反之如果想知道鼠标点击或某个像素点对应哪个网格就用gridX (pixelX - START_X) / BLOCK_SIZEgridY (pixelY - START_Y) / BLOCK_SIZE定义一个绘制单个方块一个格子的函数会非常有用void drawBlock(int gridX, int gridY, COLORREF color) { int left START_X gridX * BLOCK_SIZE; int top START_Y gridY * BLOCK_SIZE; int right left BLOCK_SIZE; int bottom top BLOCK_SIZE; // 绘制填充的矩形作为方块主体 setfillcolor(color); solidrectangle(left, top, right, bottom); // 绘制边框增加立体感 setlinecolor(RGB(64, 64, 64)); // 深灰色边框 rectangle(left, top, right, bottom); // 可选绘制高光效果让方块看起来有立体感 setlinecolor(RGB(255, 255, 255)); // 白色高光 line(left, top, right - 1, top); // 上边框 line(left, top, left, bottom - 1); // 左边框 }实操心得solidrectangle画填充矩形时边框是当前线条颜色。如果你先画填充再画边框边框可能会被填充色覆盖一部分取决于抗锯齿设置。所以更稳妥的做法是像上面一样分别绘制填充和边框。另外给方块加上简单的高光和阴影比如左上角亮边右下角暗边能极大提升视觉效果代码增加不多但质感提升明显。3.2 方块的生成、绘制与移动方块生成在游戏开始或当前方块固定后需要随机生成一个新的方块作为当前方块。CurrentBlock generateNewBlock() { CurrentBlock block; block.type rand() % 7; // 随机0-6的类型 block.rotation 0; // 初始旋转状态为0 block.x BOARD_WIDTH / 2 - 2; // 初始出现在顶部中间-2是因为4x4矩阵中心偏移 block.y 0; // 根据类型分配颜色 switch(block.type) { case SHAPE_I: block.color RGB(0, 255, 255); break; // 青 case SHAPE_J: block.color RGB(0, 0, 255); break; // 蓝 // ... 其他类型 default: block.color RGB(255, 255, 255); } return block; }方块绘制绘制当前方块就是遍历其4x4的形状矩阵对于值为1的格子调用drawBlock函数。void drawCurrentBlock(const CurrentBlock block) { for (int i 0; i 4; i) { for (int j 0; j 4; j) { if (blocks[block.type][block.rotation][i][j]) { // 注意i是矩阵的行对应垂直方向j是矩阵的列对应水平方向。 // block.x, block.y是参考点坐标需要根据形状矩阵的定位方式调整。 // 常见做法是block.x, block.y对应形状矩阵的左上角在游戏地图中的位置。 int gridX block.x j; int gridY block.y i; // 确保坐标在地图范围内再绘制对于刚生成时部分方块在地图上方的情况 if (gridY 0) { drawBlock(gridX, gridY, block.color); } } } } }方块移动与旋转移动很简单就是改变block.x或block.y。旋转则是将block.rotation在0-3之间循环。但是在真正执行移动或旋转前必须进行碰撞检测。3.3 碰撞检测游戏逻辑的基石碰撞检测是俄罗斯方块逻辑中最核心的部分它决定了方块能否移动、旋转以及何时固定。我们需要一个通用的函数给定一个虚拟的方块状态位置、旋转判断它是否与游戏地图边界或已有的方块冲突。bool checkCollision(const CurrentBlock block) { for (int i 0; i 4; i) { for (int j 0; j 4; j) { if (blocks[block.type][block.rotation][i][j]) { // 只检查方块实体部分 int gridX block.x j; int gridY block.y i; // 1. 检查是否超出左右边界 if (gridX 0 || gridX BOARD_WIDTH) { return true; // 碰撞 } // 2. 检查是否超出底部边界 (gridY BOARD_HEIGHT) if (gridY BOARD_HEIGHT) { return true; // 碰撞 } // 3. 检查是否与地图中已固定的方块重叠 (gridY 0 时才检查因为顶部上方可能为负) if (gridY 0 gameBoard[gridY][gridX] ! EMPTY) { return true; // 碰撞 } } } } return false; // 无碰撞 }有了这个函数移动和旋转的逻辑就安全了// 尝试向左移动 bool tryMoveLeft(CurrentBlock block) { CurrentBlock tempBlock block; tempBlock.x--; if (!checkCollision(tempBlock)) { block.x--; // 安全执行移动 return true; } return false; // 碰撞移动失败 } // 尝试旋转 bool tryRotate(CurrentBlock block) { CurrentBlock tempBlock block; tempBlock.rotation (tempBlock.rotation 1) % 4; // 计算旋转后的状态 if (!checkCollision(tempBlock)) { block.rotation tempBlock.rotation; return true; } // 旋转碰撞处理可以尝试“踢墙”Wall Kick // 即旋转后如果发生碰撞尝试将方块向左、右、上微调一格看是否能成功旋转。 // 这是现代俄罗斯方块的标准规则能极大改善游戏体验。 return tryWallKick(block, tempBlock); // 实现踢墙逻辑 }注意事项关于“踢墙”逻辑不同版本的俄罗斯方块如SRS标准有复杂的偏移表。对于初学者项目可以不实现或者实现一个简单的版本如果旋转后碰撞尝试将方块向左移动一格再检测如果还碰撞尝试向右移动一格。这能解决大部分靠墙旋转卡住的问题。3.4 方块固定、消行与得分当方块无法再下落时即tryMoveDown返回false就需要将其“固定”到游戏地图上。void freezeBlockToBoard(const CurrentBlock block) { for (int i 0; i 4; i) { for (int j 0; j 4; j) { if (blocks[block.type][block.rotation][i][j]) { int gridX block.x j; int gridY block.y i; // 再次检查边界防止意外虽然理论上checkCollision已阻止 if (gridX 0 gridX BOARD_WIDTH gridY 0 gridY BOARD_HEIGHT) { gameBoard[gridY][gridX] block.color; } } } } }固定后立即检查是否有行被填满即消行检测。int clearLines() { int linesCleared 0; // 从底部往上检查 for (int row BOARD_HEIGHT - 1; row 0; row--) { bool lineFull true; for (int col 0; col BOARD_WIDTH; col) { if (gameBoard[row][col] EMPTY) { lineFull false; break; } } if (lineFull) { // 将该行以上的所有行向下移动一行 for (int moveRow row; moveRow 0; moveRow--) { for (int col 0; col BOARD_WIDTH; col) { gameBoard[moveRow][col] gameBoard[moveRow - 1][col]; } } // 最顶行清空 for (int col 0; col BOARD_WIDTH; col) { gameBoard[0][col] EMPTY; } linesCleared; row; // 因为当前行被新的行覆盖需要再检查一次这个位置现在是上一行移下来的 } } return linesCleared; }消行后根据消除的行数计算得分。经典的计分规则是消除1行得100分2行得300分3行得500分4行Tetris得800分。得分可以累加并且随着等级提高每行的基础分数会增加。3.5 游戏循环与用户输入处理将以上所有模块整合到游戏主循环中。我们需要管理游戏状态是否进行、暂停、结束控制方块自动下落的速度并实时响应用户输入。// 游戏状态 enum GameState { PLAYING, PAUSED, GAME_OVER }; GameState state PLAYING; // 游戏变量 int score 0; int level 1; int dropSpeed 500; // 初始下落间隔单位毫秒 DWORD lastDropTime GetTickCount(); // 记录上次下落的时间点 CurrentBlock currentBlock generateNewBlock(); // 主循环 while (state ! GAME_OVER) { // 1. 处理输入非阻塞方式使用_kbhit和_getch if (_kbhit()) { char key _getch(); switch (key) { case a: case A: case 75: // 左箭头 if (state PLAYING) tryMoveLeft(currentBlock); break; case d: case D: case 77: // 右箭头 if (state PLAYING) tryMoveRight(currentBlock); break; case s: case S: case 80: // 下箭头 if (state PLAYING) tryMoveDown(currentBlock); break; case w: case W: case 72: // 上箭头旋转 if (state PLAYING) tryRotate(currentBlock); break; case : // 空格瞬间下落硬降 if (state PLAYING) hardDrop(currentBlock); break; case p: case P: // 暂停/继续 state (state PLAYING) ? PAUSED : PLAYING; break; case 27: // ESC退出 state GAME_OVER; break; } } // 2. 更新逻辑仅在PLAYING状态进行 if (state PLAYING) { DWORD currentTime GetTickCount(); // 定时自动下落 if (currentTime - lastDropTime dropSpeed) { if (!tryMoveDown(currentBlock)) { // 无法下落固定到地图 freezeBlockToBoard(currentBlock); int lines clearLines(); updateScoreAndLevel(lines); // 更新分数和等级等级提高后减少dropSpeed // 生成新方块 currentBlock generateNewBlock(); // 检查新方块是否立即碰撞游戏结束条件 if (checkCollision(currentBlock)) { state GAME_OVER; } } lastDropTime currentTime; } } // 3. 渲染 cleardevice(); // 清屏 drawGameBoard(); // 绘制已固定的方块地图 drawCurrentBlock(currentBlock); // 绘制当前下落方块 drawUI(score, level); // 绘制分数、等级、下一个方块预览等UI // 如果暂停绘制“PAUSED”字样 if (state PAUSED) { outtextxy(300, 250, PAUSED); } // 控制帧率避免CPU占用过高 Sleep(10); // 每帧休眠10毫秒约100FPS }实操心得使用GetTickCount()获取系统时间来控制下落间隔比简单用Sleep(dropSpeed)在循环里等待要合理得多。因为Sleep会阻塞整个循环导致用户输入无法被及时响应游戏会感觉“卡顿”。而用时间差判断既能保证固定的下落频率又能让输入处理循环保持高频运行手感更流畅。另外_kbhit()和_getch()是conio.h中的函数在Windows控制台程序或EasyX环境下可用它们提供了简单的非阻塞键盘检测。对于更复杂的输入处理可以研究EasyX的GetAsyncKeyState或消息循环。4. 进阶优化与功能扩展一个基础可玩的俄罗斯方块完成后我们可以考虑添加更多功能来提升游戏体验和代码质量。4.1 “下一个方块”预览这是一个对玩家非常重要的功能。实现起来很简单在内存中再维护一个CurrentBlock nextBlock变量。当当前方块被固定后currentBlock nextBlock然后立刻生成一个新的nextBlock。在渲染UI时在一个固定的预览区域绘制这个nextBlock即可。4.2 方块阴影Ghost Piece阴影提示方块如果立即硬降会落在什么位置是专业俄罗斯方块游戏的标配。实现思路是从当前方块位置开始模拟不断向下移动直到发生碰撞。记录下这个最终位置然后用半透明或边框的方式将这个“影子方块”绘制出来。void drawGhostPiece(const CurrentBlock block) { CurrentBlock ghost block; while (!checkCollision(ghost)) { ghost.y; } ghost.y--; // 回退到最后一个合法位置 // 用半透明或灰色边框绘制ghost setlinecolor(RGB(128, 128, 128)); // 灰色边框 setlinestyle(PS_DASH, 1); // 虚线样式 for (int i 0; i 4; i) { for (int j 0; j 4; j) { if (blocks[ghost.type][ghost.rotation][i][j]) { int gridX ghost.x j; int gridY ghost.y i; if (gridY 0) { int left START_X gridX * BLOCK_SIZE; int top START_Y gridY * BLOCK_SIZE; rectangle(left, top, left BLOCK_SIZE, top BLOCK_SIZE); } } } } setlinestyle(PS_SOLID, 1); // 恢复实线 }4.3 游戏节奏与等级系统让游戏随着时间或得分增加而变难是保持挑战性的关键。通常等级Level随消行数增加而提升。每提升一级方块自动下落的速度dropSpeed就减少一定毫秒数比如dropSpeed max(50, 500 - (level-1)*50)保证有一个最低速度。同时消行的得分倍数也可以随等级提高而增加。4.4 代码结构优化面向对象与模块化对于C项目将游戏逻辑封装成类是一个好习惯。可以设计一个Game类私有成员包含gameBoard,currentBlock,nextBlock,score,level,state等公共方法提供init(),update(),draw(),handleInput()等。这样主函数会变得非常清晰int main() { initgraph(800, 600); Game tetrisGame; tetrisGame.init(); while (tetrisGame.isRunning()) { tetrisGame.handleInput(); tetrisGame.update(); tetrisGame.draw(); Sleep(10); } closegraph(); return 0; }对于C语言虽然没有类但可以通过将相关变量放在一个结构体里函数都接受这个结构体指针作为参数来达到类似的模块化效果。5. 常见问题与调试技巧实录在开发过程中你几乎一定会遇到下面这些问题。这里记录了我的排查思路和解决方法。5.1 方块旋转时位置“跳变”或图形错误问题描述按下旋转键方块没有围绕预期中心点旋转或者形状变成了乱码。排查思路检查形状矩阵定义首先确认blocks[7][4][4][4]数组是否正确定义了所有7种方块、4种旋转状态下的4x4矩阵。一个常见的错误是矩阵数据写错或者行、列顺序弄反。可以写一个调试函数把当前方块的形状矩阵打印到控制台直观对比。确认旋转中心你的block.x和block.y代表什么是4x4矩阵的左上角还是中心点在绘制 (drawCurrentBlock) 和碰撞检测 (checkCollision) 时必须使用同一套坐标计算规则。我推荐使用矩阵左上角作为参考点这样计算最直观。对于O型方块正方形旋转不变化但其他方块在4x4矩阵中的位置需要精心设计使其旋转时视觉上围绕一个合理的中心。验证碰撞检测逻辑在tryRotate函数中打印出旋转前后的方块坐标和旋转状态然后单步调试checkCollision函数看是哪个格子的判断出了问题。避坑技巧在定义形状矩阵时可以先用注释画出图形。例如I型方块横放时// SHAPE_I, rotation 0 (横放) { {0,0,0,0}, {1,1,1,1}, // 这就是横着的I {0,0,0,0}, {0,0,0,0} }确保你的旋转状态0、1、2、3是连续且正确的。5.2 方块移动或固定后地图显示残留或错位问题描述方块移动后原来的位置没有被擦除或者固定到地图后颜色不对。排查思路渲染顺序问题确保在每一帧开始时都用cleardevice()或clearrectangle()清空了整个画面或至少游戏区域。如果只是局部更新就需要在移动方块前用背景色重绘方块原来的位置。地图数据与绘制不同步freezeBlockToBoard函数是否正确地更新了gameBoard数组检查数组下标[gridY][gridX]是否正确。记住第一维是行Y第二维是列X。颜色值错误gameBoard存储的是COLORREF颜色值。确保freezeBlockToBoard中存入的是block.color而drawGameBoard函数中读取这个值并传给drawBlock。5.3 游戏运行卡顿或输入响应迟钝问题描述游戏感觉不流畅按键有时没反应。排查思路主循环中是否有耗时操作检查clearLines函数特别是消行后移动大量数组数据的部分。如果地图很大比如100行这个操作在低性能机器上可能耗时。可以考虑优化比如只标记要消除的行在渲染时跳过积累几帧后再统一清理。输入检测方式如果你用的是scanf或阻塞式的getch肯定会卡住循环。必须使用_kbhit()配合_getch()这种非阻塞方式。帧率控制不当Sleep(10)是合理的。如果不休眠循环会全速运行占用大量CPU但可能不影响流畅度。如果Sleep时间太长比如Sleep(50)就会明显感到卡顿。可以尝试减少休眠时间。图形渲染优化EasyX默认是即时模式。如果画面元素很多可以尝试开启双缓冲。在initgraph后使用BeginBatchDraw()在每帧渲染完成后调用FlushBatchDraw()可以避免画面闪烁并可能提升性能。5.4 在VS Code或其他IDE中配置EasyX问题描述代码在老旧VC6.0里能编译但在VS Code/VS 2019/2022里报错。解决方案获取EasyX去EasyX官网下载适用于新版本Visual Studio的安装包。现在有适配VS2019/2022的版本。在VS Code中使用VS Code本身不包含C编译器。你需要先安装MinGW-w64或使用Visual Studio的编译器cl.exe。然后配置tasks.json和c_cpp_properties.json。在c_cpp_properties.json的includePath中添加EasyX头文件路径例如C:\\Program Files (x86)\\EasyX\\include\\**。在tasks.json的编译参数args中添加链接库参数。对于MinGW可能需要-lgdi32 -lole32。对于MSVC需要添加graphics.lib等库文件的具体路径。常见编译错误undefined reference to __imp_xxx这是链接错误说明没有链接正确的Windows库。确保链接了gdi32.lib,gdiplus.lib,ole32.lib,oleaut32.lib等。在VS的项目属性-链接器-输入-附加依赖项中添加它们。graphics.h: No such file or directory头文件路径没配置对。个人经验对于新手我强烈建议直接使用Visual Studio Community版它是免费的并且安装EasyX后可以直接创建Win32控制台项目添加graphics.h头文件即可编译环境问题最少。等熟悉了整个项目流程后再挑战用VS Code配置也不迟。把时间花在游戏逻辑上而不是环境配置上学习成就感会更强。5.5 游戏逻辑的一个隐藏Bug固定与消行的时序这是一个非常隐蔽但关键的逻辑点。考虑以下场景方块下落到底部tryMoveDown返回false。调用freezeBlockToBoard将其固定到gameBoard。调用clearLines消行。生成新方块currentBlock generateNewBlock()。检查新方块是否与gameBoard碰撞以判断游戏是否结束。问题如果步骤3中消行发生了gameBoard的内容会发生变化上面的行下移。但是新方块生成的位置(BOARD_WIDTH/2 - 2, 0)是在顶部。如果消行后顶部恰好有方块因为下面的行移上来了那么新方块一生成就会发生碰撞导致游戏意外结束。解决方案必须在消行之前就检查新方块是否与当前即方块固定后消行前的地图碰撞。更安全的做法是在固定方块后先检查新方块生成位置是否合法即游戏是否结束然后再进行消行和后续逻辑。或者确保generateNewBlock的初始y坐标是负数比如-1或-2让新方块从屏幕上方“出生”这样即使顶部有方块也有缓冲空间。然后在checkCollision函数中对于gridY 0的情况跳过与地图的碰撞检测但依然要检测左右边界。这才是现代俄罗斯方块的标准处理方式。