C/C++与EasyX实战:从零构建俄罗斯方块游戏

📅 2026/8/6 15:15:22
C/C++与EasyX实战:从零构建俄罗斯方块游戏
1. 项目概述为什么选择C/C和EasyX来复刻经典俄罗斯方块这个诞生于上世纪80年代的电子游戏几乎刻进了每一个程序员的DNA里。它规则简单逻辑清晰但想要用代码完美地实现它却是一个检验编程基本功和逻辑思维的绝佳试金石。市面上有无数种实现方式从网页端的JavaScript到移动端的各种引擎但今天我想带你回到最“原始”也最“硬核”的路径用纯粹的C/C配合一个轻量级的图形库——EasyX从零开始构建一个完整的、可玩的俄罗斯方块。你可能会问为什么是C/C为什么是EasyX在这个Python、JavaScript大行其道的时代这看起来像是一种“复古”。但恰恰是这种复古能让你触及计算机图形和游戏逻辑最核心的部分。C/C让你必须亲自管理内存、设计数据结构、控制每一个像素的绘制这种掌控感是高级语言和成熟引擎所无法给予的。而EasyX则是为C/C程序员打开Windows图形界面编程大门的一把钥匙。它封装了Windows GDI的底层细节提供了一套类似Turbo C/BGI风格的简单API让你无需深究复杂的Win32 API或DirectX就能快速绘制图形、处理键盘消息专注于游戏逻辑本身。这个项目适合谁如果你是C/C的初学者刚刚学完指针和结构体想找一个综合性的小项目来练手那俄罗斯方块再合适不过了。它能让你把数组、循环、条件判断、函数封装、甚至简单的状态机都用上。如果你是有一定经验的开发者想重温基础或者想给EasyX库练练手这个项目也能帮你梳理清晰的游戏框架。整个过程我们将从零开始一步步搭建游戏世界、定义方块、处理输入、实现碰撞和消行最终完成一个具备基础功能旋转、移动、加速下落、消行计分的完整游戏。我会把我在实现过程中踩过的每一个坑、每一个优化细节都分享出来目标就是让你看完之后能独立写出一个运行流畅、逻辑严谨的“方块世界”。2. 核心架构与数据结构设计在动手写第一行代码之前我们必须把游戏的“骨架”搭好。一个混乱的数据结构会让后续的逻辑代码变成一团乱麻。俄罗斯方块的核心数据其实非常清晰一个固定的游戏区域棋盘和一组在其中移动、旋转的方块Tetromino。2.1 游戏区域棋盘的建模游戏区域通常是一个二维网格比如10列宽20行高。我们可以用一个二维数组来表示它。数组的每个元素代表一个“格子”的状态是空的还是已经被固定的方块占据。#define BOARD_WIDTH 10 #define BOARD_HEIGHT 20 int board[BOARD_HEIGHT][BOARD_WIDTH]; // 0表示空非0表示有方块可以用不同数字代表不同颜色这里有一个关键细节数组的行索引第一个下标通常对应屏幕的Y坐标从上到下递增而列索引第二个下标对应X坐标从左到右递增。初始化时所有元素设为0。当方块落下并固定后就将方块所占格子在board数组中对应的位置标记为方块的颜色值。注意我们通常会在棋盘顶部预留几行作为“缓冲区域”让新方块在完全出现在可视区域前有旋转的空间。所以实际定义的BOARD_HEIGHT可能比20更大比如22或24但绘制时只绘制下面的20行。2.2 方块Tetromino的数据表示俄罗斯方块有7种基本形状每种形状由4个小方块我们称之为“方块单元”组成。我们需要一种方式来描述每种形状以及它的各种旋转状态。方法一预定义形状数组这是最直观的方法。为每种形状的每种旋转状态定义一个4x4的二维数组或一维数组表示其中1表示有方块0表示空白。// 以L形方块为例定义其4种旋转状态 int shapes[7][4][4][4]; // [形状索引][旋转状态][行][列] // 初始化会很繁琐但逻辑清晰。方法二使用模板点坐标我更推荐这种方法因为它更节省内存且旋转计算更通用。我们为每种形状定义一个“原点”和三个相对于原点的偏移坐标。typedef struct { int x, y; // 方块在棋盘上的坐标通常指某个参考点如旋转中心或左上角 int type; // 方块类型 (0-6 对应7种形状) int rotation; // 旋转状态 (0-3) } Tetromino; // 每种形状的4个方块的相对坐标以某个点为基准 const int shapeOffsets[7][4][2] { // I形 {{0, -1}, {0, 0}, {0, 1}, {0, 2}}, // J形 {{-1, -1}, {0, -1}, {0, 0}, {0, 1}}, // L形 {{-1, 1}, {0, -1}, {0, 0}, {0, 1}}, // O形 {{0, 0}, {0, 1}, {1, 0}, {1, 1}}, // S形 {{0, -1}, {0, 0}, {-1, 0}, {-1, 1}}, // T形 {{0, -1}, {0, 0}, {0, 1}, {-1, 0}}, // Z形 {{0, -1}, {0, 0}, {1, 0}, {1, 1}} };在这个结构里Tetromino的(x, y)可以定义为形状的旋转中心或左上角。通过type和rotation我们可以动态计算出当前方块4个单元在棋盘上的绝对坐标。旋转操作就变成了对rotation值的更新和坐标的变换。2.3 游戏状态与全局变量设计我们需要一些全局变量来维护游戏的整个状态。int g_board[BOARD_HEIGHT][BOARD_WIDTH]; // 全局棋盘 Tetromino g_currentPiece; // 当前正在下落的方块 Tetromino g_nextPiece; // 下一个预览方块 int g_score 0; // 当前分数 int g_level 1; // 当前等级决定下落速度 int g_linesCleared 0; // 已消除的行数 bool g_gameOver false; // 游戏是否结束把状态集中管理而不是散落在各个函数里用局部变量传递会让逻辑更清晰。当然更面向对象的方式是封装成一个Game结构体但对于C语言入门项目全局变量在可控范围内是更简单的选择。3. EasyX图形库的初始化与基础绘制EasyX的安装非常简单去官网下载对应你的Visual Studio版本的安装包一键安装即可。安装后在VS项目中包含graphics.h头文件就能使用所有函数。3.1 窗口初始化与坐标系游戏的第一步是创建一个窗口。#include graphics.h // EasyX图形库头文件 #include conio.h // 用于键盘输入 int main() { initgraph(640, 480); // 初始化一个640x480的图形窗口 // 你的游戏主循环... closegraph(); // 关闭图形窗口 return 0; }initgraph创建的窗口默认坐标系原点(0,0)在左上角X轴向右为正Y轴向下为正。这对于我们的棋盘绘制非常直观。3.2 绘制基础元素棋盘与方块绘制棋盘就是根据g_board数组在屏幕上画出一系列填充的矩形。void drawBoard() { const int cellSize 24; // 每个格子的像素大小 const int offsetX 50; // 棋盘左上角在窗口中的X偏移 const int offsetY 50; // 棋盘左上角在窗口中的Y偏移 setlinecolor(DARKGRAY); // 设置网格线颜色 setfillcolor(BLACK); // 设置棋盘背景色 // 绘制棋盘背景和网格 for (int y 0; y VISIBLE_HEIGHT; y) { // 只绘制可见部分 for (int x 0; x BOARD_WIDTH; x) { int posX offsetX x * cellSize; int posY offsetY y * cellSize; // 填充格子背景 solidrectangle(posX, posY, posX cellSize, posY cellSize); // 绘制格子边框 rectangle(posX, posY, posX cellSize, posY cellSize); } } // 绘制已固定的方块 for (int y 0; y BOARD_HEIGHT; y) { for (int x 0; x BOARD_WIDTH; x) { if (g_board[y][x] ! 0) { // 有方块 int posX offsetX x * cellSize; int posY offsetY (y - (BOARD_HEIGHT - VISIBLE_HEIGHT)) * cellSize; // 注意Y坐标转换 setfillcolor(getColorByType(g_board[y][x])); // 根据存储的类型值获取颜色 solidrectangle(posX 1, posY 1, posX cellSize - 1, posY cellSize - 1); // 留出边框 } } } }绘制当前下落的方块g_currentPiece逻辑类似但需要根据其type,rotation,x,y实时计算四个单元的位置并绘制。注意下落中的方块应该用半透明或不同的边框颜色绘制以和已固定的方块区分。3.3 绘制游戏UI分数、等级、下一个方块预览除了主棋盘我们还需要在窗口的侧边或顶部绘制游戏信息。void drawUI() { settextcolor(LIGHTGRAY); settextstyle(20, 0, _T(宋体)); int infoX 50 BOARD_WIDTH * 24 30; // 在棋盘右侧绘制 // 绘制分数 TCHAR scoreStr[50]; _stprintf(scoreStr, _T(分数: %d), g_score); outtextxy(infoX, 50, scoreStr); // 绘制等级 _stprintf(scoreStr, _T(等级: %d), g_level); outtextxy(infoX, 80, scoreStr); // 绘制下一个方块预览 outtextxy(infoX, 120, _T(下一个:)); drawPiecePreview(g_nextPiece, infoX, 150); // 单独实现预览方块的绘制函数 }drawPiecePreview函数会在一个小区域内绘制g_nextPiece的形状让玩家有预判。4. 游戏核心逻辑的实现图形绘制只是“面子”游戏逻辑才是“里子”。俄罗斯方块的核心逻辑循环可以概括为处理输入 - 更新状态 - 碰撞检测 - 固定方块/消行 - 绘制。4.1 主游戏循环与时间控制游戏不能跑得太快或太慢我们需要一个稳定的时间帧率。EasyX提供了一个GetAsyncKeyState函数来检测按键但更常用的方法是结合kbhit()和_getch()来处理即时按键同时用计时器控制方块自动下落。void gameLoop() { DWORD lastFallTime GetTickCount(); // 记录上次方块下落的时间 int fallInterval 1000 - (g_level - 1) * 100; // 下落间隔随等级缩短最低不低于100ms while (!g_gameOver) { // 1. 处理输入 processInput(); DWORD currentTime GetTickCount(); // 2. 定时下落 if (currentTime - lastFallTime fallInterval) { movePieceDown(); // 尝试向下移动 lastFallTime currentTime; } // 3. 碰撞检测在movePieceDown内部已做 // 4. 消行检查在方块固定后触发 // 5. 绘制 cleardevice(); // 清屏 drawBoard(); drawCurrentPiece(); drawUI(); // 为了防止画面闪烁可以启用双缓冲这里先使用简单模式 FlushBatchDraw(); // 如果启用了双缓冲则需要这个函数 Sleep(10); // 让出一点点CPU时间避免空转耗资源 } }4.2 方块移动与旋转的碰撞检测这是游戏逻辑中最关键的部分。任何移动左、右、下或旋转操作前都必须先进行“可行性检测”。检测函数原型bool isValidPosition(const Tetromino* piece, int newX, int newY, int newRotation);这个函数根据传入的方块参数新位置、新旋转状态计算其四个单元在棋盘上的目标位置然后检查是否超出棋盘左右边界和下边界。目标位置在g_board数组中是否已经被其他固定方块占据即值不为0。如果所有目标格子都是空的且在边界内则返回true否则返回false。移动的实现void movePieceLeft() { if (isValidPosition(g_currentPiece, g_currentPiece.x - 1, g_currentPiece.y, g_currentPiece.rotation)) { g_currentPiece.x--; } } void movePieceRight() { /* 类似 */ } bool movePieceDown() { // 返回是否成功下落 if (isValidPosition(g_currentPiece, g_currentPiece.x, g_currentPiece.y 1, g_currentPiece.rotation)) { g_currentPiece.y; return true; } else { // 无法下落说明到底了需要固定方块 lockPiece(); return false; } }旋转的实现旋转稍微复杂一点因为需要计算旋转后的新坐标。对于“模板点坐标”表示法旋转可以简化为对rotation变量的更新和坐标变换。通常旋转是顺时针的旋转中心一般是方块的某个点对于大多数方块是四个单元中的一个或它们围成的中心。void rotatePiece() { int oldRotation g_currentPiece.rotation; int newRotation (oldRotation 1) % 4; // 假设有4种旋转状态 // 先尝试直接旋转 if (isValidPosition(g_currentPiece, g_currentPiece.x, g_currentPiece.y, newRotation)) { g_currentPiece.rotation newRotation; return; } // 如果直接旋转失败尝试“踢墙”Wall Kick // 这是俄罗斯方块官方规范当旋转后发生碰撞时让方块尝试向左右微调一格 int kickOffsets[5][2] {{0,0}, {-1,0}, {1,0}, {0,-1}, {0,1}}; // 尝试的偏移量 for (int i 0; i 5; i) { if (isValidPosition(g_currentPiece, g_currentPiece.x kickOffsets[i][0], g_currentPiece.y kickOffsets[i][1], newRotation)) { g_currentPiece.x kickOffsets[i][0]; g_currentPiece.y kickOffsets[i][1]; g_currentPiece.rotation newRotation; return; } } // 所有踢墙尝试都失败则放弃本次旋转 }实操心得实现“踢墙”逻辑是让游戏手感更专业的关键。否则方块在靠近墙壁旋转时会显得非常僵硬。对于I形和O形方块踢墙表会更复杂但基础版本实现上述几种偏移已经能解决大部分情况。4.3 方块固定与消行逻辑当方块无法继续下落时需要将其“固化”到棋盘上。void lockPiece() { // 1. 将当前方块的四个单元写入g_board数组 for (int i 0; i 4; i) { int cellX g_currentPiece.x shapeOffsets[g_currentPiece.type][g_currentPiece.rotation][i][0]; int cellY g_currentPiece.y shapeOffsets[g_currentPiece.type][g_currentPiece.rotation][i][1]; if (cellY 0) { // 确保在棋盘有效范围内顶部缓冲区的可能为负 g_board[cellY][cellX] g_currentPiece.type 1; // 用非0值标记type1便于区分颜色 } } // 2. 检查消行 checkLineClear(); // 3. 生成新的当前方块即把预览方块拿来用 g_currentPiece g_nextPiece; // 重置新方块的位置到顶部中央 g_currentPiece.x BOARD_WIDTH / 2 - 1; g_currentPiece.y 0; // 或一个负的起始Y值让它从顶部外出现 // 4. 生成新的预览方块 generateNextPiece(); // 5. 立即检查游戏是否结束如果新方块一出现就发生碰撞则游戏结束 if (!isValidPosition(g_currentPiece, g_currentPiece.x, g_currentPiece.y, g_currentPiece.rotation)) { g_gameOver true; } }消行逻辑checkLineClear()这是游戏的爽点来源。我们需要从下往上扫描棋盘的每一行。void checkLineClear() { int linesToClear[4] {0}; // 记录需要消除的行号 int lineCount 0; // 从下往上扫描 for (int y BOARD_HEIGHT - 1; y 0; y--) { bool lineFull true; for (int x 0; x BOARD_WIDTH; x) { if (g_board[y][x] 0) { lineFull false; break; } } if (lineFull) { linesToClear[lineCount] y; } } if (lineCount 0) return; // 计分经典规则同时消除的行数越多分数倍数越高 int scoreAdd 0; switch (lineCount) { case 1: scoreAdd 100 * g_level; break; case 2: scoreAdd 300 * g_level; break; case 3: scoreAdd 500 * g_level; break; case 4: scoreAdd 800 * g_level; break; // Tetris! } g_score scoreAdd; g_linesCleared lineCount; g_level g_linesCleared / 10 1; // 每消除10行升一级 // 消除行将上面的行整体下移 for (int i 0; i lineCount; i) { int lineY linesToClear[i]; // 从要消除的行开始将上面的每一行向下复制 for (int y lineY; y 0; y--) { for (int x 0; x BOARD_WIDTH; x) { g_board[y][x] g_board[y-1][x]; } } // 最顶行清空 for (int x 0; x BOARD_WIDTH; x) { g_board[0][x] 0; } // 因为消除了一行上面所有行的索引都变了需要调整linesToClear中剩余的行号 for (int j i 1; j lineCount; j) { if (linesToClear[j] linesToClear[i]) { linesToClear[j]; } } } }踩过的坑消行后上面行的索引会变化。如果同时消除多行比如第19行和第20行先消除第20行后第19行就变成了新的第20行。上面的代码通过动态调整linesToClear数组来处理这个情况。更简单但效率稍低的方法是每次消除一行后就重新从底部开始扫描。4.4 用户输入处理与即时响应玩家体验的流畅度很大程度上取决于输入响应。我们需要处理左右移动、旋转、加速下落软降和直接落底硬降。void processInput() { if (_kbhit()) { // 检查是否有按键按下 int key _getch(); switch (key) { case a: case A: case 75: // 左箭头键 movePieceLeft(); break; case d: case D: case 77: // 右箭头键 movePieceRight(); break; case s: case S: case 80: // 下箭头键 (软降) movePieceDown(); break; case w: case W: case 72: // 上箭头键 rotatePiece(); break; case : // 空格键 (硬降) hardDrop(); break; case p: case P: // 暂停 togglePause(); break; } } }硬降Hard Drop的实现void hardDrop() { while (movePieceDown()) { // 不断向下移动直到不能移动为止 // 可以在这里增加一个视觉反馈比如方块闪烁一下 } // movePieceDown返回false时lockPiece已经被调用 }关于按键重复上面的简单处理在长按时会有延迟感。为了更好的手感可以记录按键状态在游戏循环中根据按键持续时间来实现“长按加速移动”。这需要更精细的状态管理例如记录每个动作键的按下时间当按住超过一个阈值如200ms后以更快的频率如每50ms重复触发移动。5. 功能增强与细节打磨一个基础版本完成后我们可以添加更多功能让它更像一个完整的游戏。5.1 随机方块生成与“随机种子”不能让方块的出现顺序完全随机否则可能出现极端情况比如连续给你10个S形方块。标准的做法是使用“7-Bag”随机生成器。它保证在连续的7个方块中每种形状恰好出现一次顺序随机。int bag[7] {0, 1, 2, 3, 4, 5, 6}; // I, J, L, O, S, T, Z int bagIndex 7; // 初始指向bag末尾表示需要生成新的一袋 void shuffleBag() { // 使用Fisher-Yates洗牌算法打乱bag数组 for (int i 6; i 0; i--) { int j rand() % (i 1); int temp bag[i]; bag[i] bag[j]; bag[j] temp; } bagIndex 0; // 重置索引 } int getNextPieceType() { if (bagIndex 7) { shuffleBag(); } return bag[bagIndex]; } void generateNextPiece() { g_nextPiece.type getNextPieceType(); g_nextPiece.rotation 0; // 预览方块的位置可以单独定义比如在预览区中央 }5.2 阴影Ghost Piece提示阴影提示玩家方块最终会落在哪里是非常友好的设计。实现原理是从当前方块位置开始模拟一直向下移动直到碰撞然后在这个最终位置绘制一个半透明或边框状态的方块。void drawGhostPiece() { Tetromino ghost g_currentPiece; // 模拟下落 while (isValidPosition(ghost, ghost.x, ghost.y 1, ghost.rotation)) { ghost.y; } // 绘制ghost可以用半透明颜色或虚线边框 setlinecolor(LIGHTGRAY); setlinestyle(PS_DASH, 1); // 设置虚线样式 // ... 计算ghost的四个点并绘制矩形边框 ... setlinestyle(PS_SOLID, 1); // 恢复实线 }5.3 音效与动画效果虽然EasyX本身不直接支持音频播放但可以通过Windows APIPlaySound来播放简单的WAV音效为消行、旋转、落底等动作添加反馈。#include windows.h #pragma comment(lib, winmm.lib) // 链接Winmm库 void playClearSound() { PlaySound(_T(clear.wav), NULL, SND_FILENAME | SND_ASYNC); }对于消行动画可以在checkLineClear检测到要消行时不立即删除而是先标记这些行在主循环中绘制几帧高亮或闪烁效果然后再执行消除和下落逻辑。这需要引入一个简单的动画状态机。5.4 游戏状态的保存与暂停/继续暂停功能很简单设置一个全局布尔变量g_isPaused在主循环中如果暂停就跳过状态更新和输入处理或者只处理“取消暂停”的输入。bool g_isPaused false; void togglePause() { g_isPaused !g_isPaused; } void gameLoop() { // ... while (!g_gameOver) { if (!g_isPaused) { processInput(); // ... 更新游戏状态 ... } else { // 暂停时可以绘制“PAUSED”文字 drawPauseScreen(); } // ... 绘制 ... } }6. 项目编译、调试与优化心得6.1 在Visual Studio中配置项目创建新项目选择“空项目”或“控制台应用”。添加源文件将你的.c或.cpp文件添加到源文件目录。包含EasyX确保graphics.h等头文件路径正确。通常安装EasyX后VS会自动配置。字符集设置如果你的代码中使用了中文比如outtextxy输出中文需要将项目的“字符集”设置为“使用多字节字符集”项目属性 - 配置属性 - 高级 - 字符集。编译运行直接按F5编译并运行。如果遇到“无法找到graphics.h”的错误请检查EasyX是否安装正确。6.2 常见编译错误与运行时问题“undefined reference to__imp_xxxxx”这通常是链接库的问题。确保项目链接了必要的库如libgdi32.a,libole32.a等。EasyX安装包通常会处理好这些。画面闪烁严重这是因为直接在屏幕上绘制。解决方案是使用双缓冲。EasyX中在initgraph之后立即调用BeginBatchDraw()在每帧绘制完成后调用FlushBatchDraw()最后在closegraph前调用EndBatchDraw()。所有绘图操作都在内存中进行完成后一次性刷新到屏幕能极大消除闪烁。键盘输入不灵敏或粘连使用_kbhit()和_getch()是即时模式。对于需要长按加速的需求需要用GetAsyncKeyState来查询按键状态。注意_getch()会阻塞等待输入不适合放在需要实时响应的主循环中作为唯一输入方式通常两者结合使用。游戏速度不稳定依赖Sleep来控制帧率不精确。更专业的做法是使用高精度计时器如QueryPerformanceCounter计算帧时间差deltaTime然后根据时间差来更新游戏状态例如方块下落距离 下落速度 * deltaTime。这对于需要恒定物理模拟的游戏更重要对于方块下落这种离散移动用定时器基本足够。6.3 代码结构优化建议当所有功能都塞在main.c里后代码会变得很长。良好的习惯是分模块game.h/game.c: 声明和定义游戏状态、核心逻辑函数移动、旋转、碰撞、消行。graphics.h/graphics.c: 所有与EasyX绘制相关的函数绘制棋盘、方块、UI。input.h/input.c: 处理键盘输入的函数。main.c: 包含main函数组织主循环调用各个模块。这样不仅代码清晰也便于多人协作和后期维护。6.4 性能与可玩性微调下落速度曲线不要让等级无限加速。可以设置一个最大下落速度比如fallInterval不低于50ms否则人类根本无法反应。初始等级选择可以让玩家选择起始等级增加游戏挑战性。控制手感移动的“重复延迟”和“重复速度” (DAS和ARR) 是专业俄罗斯方块玩家非常在意的参数。可以在代码中实现当按住左/右键时先等待一个较长的延迟如200ms然后以较快的间隔如30ms重复触发移动。这能让方块定位更精准。方块旋转系统我们实现的是最简单的旋转。标准的俄罗斯方块有“Super Rotation System”SRS为每种形状的每种旋转状态定义了精确的“踢墙”表。如果你想追求极致的还原度可以去查阅SRS的详细规范并实现它。从在控制台打印字符方块到在图形窗口里看到色彩斑斓的方块落下、旋转、消行这个过程充满了成就感。用C/C和EasyX实现俄罗斯方块远不止是完成一个小游戏它是一次对基础编程能力、逻辑思维和问题分解能力的全面锻炼。当你看到自己写的程序流畅运行并且可以随手加上一个阴影提示、一个消行动画时那种对代码的掌控感和创造力是无可替代的。希望这篇超详细的指南能成为你探索游戏编程世界的一块坚实垫脚石。如果在实现过程中遇到任何问题随时可以带着你的代码来交流很多“坑”只有踩过才知道怎么绕过去。