C/C++与EasyX图形库:从零实现俄罗斯方块的完整游戏开发指南
2026/8/6 3:02:33 网站建设 项目流程

1. 项目缘起与核心价值

最近在整理硬盘时,翻出了大学时期用C语言和EasyX图形库写的俄罗斯方块。看着那简陋但运行流畅的方块,突然有点感慨。现在各种游戏引擎、框架层出不穷,Unity、Unreal、Cocos,功能强大到令人眼花缭乱。但有时候,回归最原始的语言和最简单的库,从零开始构建一个完整的游戏逻辑,反而能让你对编程、对游戏开发有更深刻的理解。这就像学武术,花架子固然好看,但扎马步、练基本功才是根基。

这个项目,就是用C/C++配合EasyX图形库,从零实现一个经典的俄罗斯方块游戏。我把它重新梳理、优化,并补充了大量当时没想明白或者偷懒略过的细节,最终形成了这篇近四万字的“爆肝”指南。它完全免费,目的就是带你走一遍完整的游戏开发小闭环:从环境搭建、图形绘制、到核心的游戏逻辑、状态管理、再到用户交互和细节打磨。你不需要有任何游戏引擎的经验,只要对C/C++有基本了解,就能跟着做下来。完成之后,你收获的不仅仅是一个能运行的俄罗斯方块,更是一套解决同类2D小游戏问题的通用思路和方法论。无论是贪吃蛇、打砖块,还是更复杂的棋类游戏,其底层的数据结构、状态更新、渲染循环都是相通的。

2. 开发环境搭建与EasyX初探

工欲善其事,必先利其器。我们的核心工具链是Visual Studio(推荐2019或2022社区版)和EasyX图形库。选择它们的原因很简单:VS在Windows下的C++开发体验无可挑剔,调试功能强大;而EasyX是一个为C/C++量身定做的图形库,API极其简单直观,完全屏蔽了Windows底层复杂的GDI绘图细节,让我们能像在TC的graphics.h里一样轻松画图,但又支持现代VS编译器。

2.1 Visual Studio与EasyX安装

首先,去微软官网下载并安装Visual Studio Community版。安装时,在“工作负载”中务必勾选“使用C++的桌面开发”。安装完成后,打开VS新建一个“空项目”。

接下来是EasyX。不要去官网下载那个在线安装包,它有时会出问题。我推荐直接去GitHub上搜索“EasyX”的仓库,下载其Release版本中的.h.lib文件。通常你会得到easyx.hgraphics.h以及对应不同编译器的lib文件(如libeasyx.aeasyx.lib等)。

安装步骤其实是将这些文件放到正确的位置:

  1. easyx.hgraphics.h复制到你的VS安装目录下的VC\Tools\MSVC\{版本号}\include文件夹内。{版本号}是一串数字,找到你安装的MSVC版本对应的文件夹即可。
  2. lib文件(例如easyx.lib)复制到VC\Tools\MSVC\{版本号}\lib\{目标平台}{目标平台}通常是x64x86,根据你项目设定的平台选择。为了省事,你可以同时复制到x64x86目录下。

注意:网上有些教程会让你把文件放到系统盘Program Files下的Microsoft SDKs目录,那是旧版VC6或特定SDK的路径,对于新版VS,放在MSVC的目录下是最直接有效的。

验证安装是否成功:在VS中新建一个.cpp源文件,输入以下代码:

#include <graphics.h> // 引用 EasyX 图形库 #include <conio.h> int main() { initgraph(640, 480); // 创建一个 640x480 的绘图窗口 circle(200, 200, 100); // 画圆,圆心(200,200),半径100 getch(); // 按任意键继续 closegraph(); // 关闭图形窗口 return 0; }

编译运行。如果弹出一个黑色窗口并在指定位置画了一个白色圆圈,恭喜你,环境配置成功。如果报错“无法打开源文件graphics.h”,请检查头文件路径;如果报链接错误,请检查lib文件是否放对了平台目录。

2.2 项目配置要点

在项目属性中,有几个地方需要注意:

  • 字符集:在“项目属性 -> 配置属性 -> 高级 -> 字符集”中,建议设置为“使用多字节字符集”。EasyX的文本输出函数(如outtextxy)默认支持多字节(中文),如果使用Unicode字符集,输出中文可能需要额外转换。
  • 子系统:控制台应用默认是“控制台(/SUBSYSTEM:CONSOLE)”,这会在运行图形窗口的同时保留一个控制台窗口。如果你不想要那个黑框框,可以改为“窗口(/SUBSYSTEM:WINDOWS)”。不过调试时,控制台窗口可以方便地输出日志信息,各有利弊。
  • 预编译头:对于这个小项目,可以关闭预编译头以简化设置(在“项目属性 -> C/C++ -> 预编译头”中选择“不使用预编译头”)。

3. 游戏核心数据结构设计

俄罗斯方块的核心是“棋盘”和“方块”。如何用代码优雅地表示它们,直接决定了后续逻辑的复杂度。

3.1 棋盘(Game Board)的表示

棋盘是一个二维网格,每个格子只有两种状态:有方块(被占据)或空。最直观的方法是用一个二维数组(或一维数组模拟二维)来表示。

#define BOARD_WIDTH 10 #define BOARD_HEIGHT 20 int board[BOARD_HEIGHT][BOARD_WIDTH] = {0}; // 0表示空,非0(如1)表示有方块

这里BOARD_HEIGHT是可视区域的高度,通常为20行。但实际编程中,我们往往会在顶部预留几行“隐藏区”,用于生成和预览新方块。所以实际的数组行数可能比BOARD_HEIGHT大。

为什么用int而不是bool?因为后续我们可能想用不同的数字代表不同颜色或类型的残留方块,方便实现“皮肤”功能。0作为空值的约定非常牢固。

3.2 方块(Tetromino)的表示

俄罗斯方块有7种基本形状,每种形状有4种旋转状态。如何表示一个方块?

  1. 局部坐标系:每个方块可以用一个4x4的小矩阵(二维数组)来描述其形状。矩阵中,1表示有格子,0表示无。
    // 例如,“L”形的一种旋转状态 int shapeL[4][4] = { {0, 0, 0, 0}, {0, 1, 0, 0}, {0, 1, 0, 0}, {0, 1, 1, 0} };
  2. 预定义所有状态:我们可以预先定义好所有7种形状、4种旋转的矩阵数据。这是一个体力活,但一劳永逸。更高效的方法是只定义每个形状的“初始状态”,然后通过旋转算法动态计算其他状态。但对于入门项目,预定义所有状态更简单可靠。
  3. 方块对象:一个活动的方块,我们需要记录它的:类型(7种之一)、当前旋转状态(0-3)、在棋盘上的位置(左上角坐标(x, y))、颜色。
    struct Tetromino { int type; // 方块类型 int rotation; // 旋转状态 (0-3) int posX, posY; // 在棋盘网格中的坐标(通常是左上角参考点) COLORREF color; // EasyX的颜色值 // 甚至可以存储当前形状的4x4矩阵指针或数据 };

3.3 颜色与资源定义

EasyX使用COLORREF类型表示颜色,可以用RGB(r, g, b)宏来定义。为每种方块类型分配一个醒目的颜色,能极大提升游戏视觉效果。

// 定义7种经典颜色对应7种方块 const COLORREF COLORS[7] = { RGB(0, 255, 255), // I: 青色 RGB(0, 0, 255), // J: 蓝色 RGB(255, 165, 0), // L: 橙色 RGB(255, 255, 0), // O: 黄色 RGB(0, 255, 0), // S: 绿色 RGB(255, 0, 0), // Z: 红色 RGB(128, 0, 128) // T: 紫色 };

将棋盘网格的绘制颜色(背景、网格线)与方块颜色分开定义,方便后期调整主题。

4. 核心游戏逻辑实现

有了数据结构,接下来就是让游戏“动”起来的逻辑。这是最核心也最有趣的部分。

4.1 游戏主循环架构

任何游戏都有一个核心循环,即“游戏循环”。对于俄罗斯方块,一个经典的循环结构如下:

void gameLoop() { initGame(); // 初始化游戏状态 while (!gameOver) { double startTime = getCurrentTime(); // 记录循环开始时间 processInput(); // 处理用户输入(键盘事件) updateGameState(); // 更新游戏状态(方块下落、消行判断等) render(); // 渲染画面到屏幕 double endTime = getCurrentTime(); double frameTime = endTime - startTime; // 控制帧率,例如每秒60帧,则每帧约16.7ms if (frameTime < FRAME_TIME) { Sleep(FRAME_TIME - frameTime); // Windows下用Sleep,精度不高但简单 } } closeGame(); // 游戏结束,清理资源 }

这里的关键是状态更新与渲染分离updateGameState根据时间或固定间隔驱动方块下落,而render则负责将当前board和活动Tetromino的状态绘制出来。输入处理可以即时响应,也可以放入队列在更新阶段处理。

4.2 方块的下落与移动控制

方块的下落是游戏的基本驱动。有两种常见模式:

  1. 基于时间的连续下落:每次updateGameState时,检查自上次下落过去的时间是否超过一个间隔(如500毫秒),如果是,则让方块的posY++。这能实现平滑的下落感。
  2. 基于帧的离散下落:每固定帧数(如每30帧)让方块下落一格。这与游戏帧率耦合,不如时间驱动稳定。

我更推荐时间驱动。你需要记录一个“下落计时器”。在updateGameState中:

currentTime = getTickCount(); // 获取系统毫秒数 if (currentTime - lastDropTime > dropInterval) { if (canMove(currentPiece, 0, 1)) { // 判断能否向下移动 currentPiece.posY++; // 可以,则下落 } else { // 不能下落,说明到底了,固定方块到棋盘 lockPiece(currentPiece); // 检查并消除满行 checkLineClear(); // 生成新的方块 spawnNewPiece(); // 如果新方块一出生就无法放置,则游戏结束 if (!isValidPosition(newPiece)) { gameOver = true; } } lastDropTime = currentTime; // 重置计时器 }

canMove函数是核心中的核心,它需要判断方块在目标位置是否与棋盘已有方块重叠或超出边界。

4.3 碰撞检测的实现

碰撞检测函数canMoveisValidPosition的逻辑如下:

  1. 遍历当前活动方块形状矩阵(4x4)中的每一个为1的格子。
  2. 计算这个格子在棋盘上的绝对坐标:boardX = piece.posX + cellX; boardY = piece.posY + cellY;
  3. 进行三项检查:
    • 左/右边界boardX < 0 || boardX >= BOARD_WIDTH
    • 下边界boardY >= BOARD_HEIGHT(注意,顶部隐藏区允许boardY < 0
    • 与棋盘已有方块碰撞:如果boardY >= 0(即进入可视区域)且board[boardY][boardX] != 0
  4. 任何一项检查失败,则位置非法。

旋转的碰撞检测更复杂一些。通常采用“踢墙”策略:当旋转后发生碰撞时,尝试将方块向左、右、上微调一个格子,如果某个位置合法,则允许旋转并移动过去。这是官方俄罗斯方块的规则,实现起来稍繁琐,但能极大改善操作手感。初期可以先实现简单的“能转就转,不能转就不转”的逻辑。

4.4 消行与分数计算

当方块被锁定后,需要检查是否有行被填满。

void checkLineClear() { int linesCleared = 0; // 从下往上检查,因为从底部消行更符合直觉 for (int y = BOARD_HEIGHT - 1; y >= 0; y--) { bool lineFull = true; for (int x = 0; x < BOARD_WIDTH; x++) { if (board[y][x] == 0) { lineFull = false; break; } } if (lineFull) { // 消除这一行:将上面的所有行下移 for (int yy = y; yy > 0; yy--) { for (int xx = 0; xx < BOARD_WIDTH; xx++) { board[yy][xx] = board[yy - 1][xx]; } } // 最顶行清零 for (int xx = 0; xx < BOARD_WIDTH; xx++) { board[0][xx] = 0; } y++; // 因为当前行被移下来了,需要再检查一次同一位置(现在是上一行) linesCleared++; } } // 根据消除的行数更新分数 if (linesCleared > 0) { updateScore(linesCleared); } }

分数计算可以很简单,如消除1行得100分,2行得300分,3行得500分,4行(Tetris)得800分。随着分数增加,可以逐步减少dropInterval,提高游戏难度。

5. 图形渲染与用户交互

逻辑是骨架,渲染和交互则是血肉,决定了游戏的观感和手感。

5.1 使用EasyX绘制棋盘与方块

EasyX的绘图基于“设备”概念。我们所有的绘图操作都在initgraph创建的窗口中进行。

  • 绘制棋盘背景和网格
    void drawBoard() { // 清屏或用背景色填充 setbkcolor(BACKGROUND_COLOR); cleardevice(); // 绘制棋盘区域背景 setfillcolor(BOARD_COLOR); fillrectangle(BOARD_ORIGIN_X, BOARD_ORIGIN_Y, BOARD_ORIGIN_X + BLOCK_SIZE * BOARD_WIDTH, BOARD_ORIGIN_Y + BLOCK_SIZE * BOARD_HEIGHT); // 绘制网格线 setlinecolor(GRID_COLOR); for (int x = 0; x <= BOARD_WIDTH; x++) { int px = BOARD_ORIGIN_X + x * BLOCK_SIZE; line(px, BOARD_ORIGIN_Y, px, BOARD_ORIGIN_Y + BLOCK_SIZE * BOARD_HEIGHT); } for (int y = 0; y <= BOARD_HEIGHT; y++) { int py = BOARD_ORIGIN_Y + y * BLOCK_SIZE; line(BOARD_ORIGIN_X, py, BOARD_ORIGIN_X + BLOCK_SIZE * BOARD_WIDTH, py); } }
  • 绘制已固定的方块:遍历board数组,对于非零值,根据其值(或颜色索引)在对应网格位置绘制一个填充矩形。
  • 绘制当前活动方块:遍历活动方块的形状矩阵,为每个为1的格子,在(posX+cellX, posY+cellY)的网格位置,用方块的颜色绘制一个填充矩形。为了美观,可以在矩形内部再画一个颜色稍浅的小矩形作为高光,或者绘制圆角矩形。
  • 绘制预览方块(Next):在棋盘旁边开辟一个区域,用同样的方法绘制下一个即将出现的方块,让玩家有准备时间。

5.2 键盘输入与事件处理

EasyX提供了_kbhit()_getch()函数来检测和控制台输入,但在图形窗口模式下,更推荐使用其消息循环或专门的键盘状态检查函数GetAsyncKeyState

void processInput() { // 检查当前按键状态,响应更即时 if (GetAsyncKeyState(VK_LEFT) & 0x8000) { // 左移 if (canMove(currentPiece, -1, 0)) currentPiece.posX--; } if (GetAsyncKeyState(VK_RIGHT) & 0x8000) { // 右移 if (canMove(currentPiece, 1, 0)) currentPiece.posX++; } if (GetAsyncKeyState(VK_DOWN) & 0x8000) { // 加速下落(软降) if (canMove(currentPiece, 0, 1)) { currentPiece.posY++; score += 1; // 软降奖励分数 } } if (GetAsyncKeyState(VK_UP) & 0x8000) { // 旋转,这里需要处理旋转的碰撞检测和踢墙 tryRotate(currentPiece); } if (GetAsyncKeyState(VK_SPACE) & 0x8000) { // 硬降,直接落到底部 hardDrop(currentPiece); } }

这里有一个关键点:GetAsyncKeyState是“立即”的,如果放在循环里不处理,按键会持续生效导致方块移动过快。常见的做法是引入“按键延迟”和“重复速率”:第一次按下立即响应,之后延迟一段时间再开始连续响应。或者,更游戏化的处理是,在updateGameState中,每帧只处理一次移动,通过一个“移动方向”变量来记录输入意图。

5.3 游戏状态信息的显示

除了主棋盘,还需要在窗口的侧边或底部显示游戏状态信息:

  • 分数(Score):使用outtextxy函数在指定位置输出文本。settextcolorsettextstyle可以设置颜色和字体大小。
  • 等级(Level):通常随分数提高而增加,等级越高,方块下落速度越快。
  • 已消除行数(Lines)
  • 下一个方块预览(Next)
  • 控制说明

例如:

void drawUI() { settextcolor(UI_TEXT_COLOR); settextstyle(20, 0, _T("宋体")); TCHAR str[100]; _stprintf_s(str, _T("分数: %d"), score); outtextxy(UI_SCORE_X, UI_SCORE_Y, str); _stprintf_s(str, _T("等级: %d"), level); outtextxy(UI_LEVEL_X, UI_LEVEL_Y, str); // ... 绘制其他信息 }

注意:EasyX的文本输出函数默认使用TCHAR字符串,如果你的项目是多字节字符集,直接使用双引号字符串即可;如果是Unicode,需要使用_T()宏或L前缀。

6. 高级功能与细节打磨

基础功能完成后,可以添加一些提升游戏体验的功能。

6.1 阴影(Ghost Piece)的实现

阴影提示方块如果立即硬降会落在哪里,是非常友好的设计。实现原理:

  1. 复制当前活动方块。
  2. 循环执行canMove检查,让这个复制品不断向下移动,直到无法移动为止。
  3. 记录最终位置。
  4. render函数中,在绘制当前方块之前,用半透明或虚线边框的方式,在最终位置绘制这个“幽灵方块”。

这能帮助玩家更精准地进行摆放。

6.2 暂停、重新开始与游戏结束逻辑

  • 暂停:在游戏循环中设置一个isPaused标志。当按下暂停键(如P)时,翻转这个标志。在updateGameState中,如果isPaused为真,则跳过所有状态更新,但render函数仍然可以绘制一个“暂停”覆盖层。
  • 重新开始:将board数组清零,重置分数、等级、下落速度等所有状态变量,然后调用spawnNewPiece生成第一个方块。
  • 游戏结束:当新方块无法被放置时,触发游戏结束。可以显示“Game Over”字样,并停止游戏循环。等待玩家按下某个键(如回车)后重新开始。

6.3 音效与动画(进阶)

EasyX本身不直接支持音频播放。你可以使用Windows APIPlaySound来播放简单的.wav文件,实现移动、旋转、消行、游戏结束的音效。注意音效文件的管理和异步播放,避免阻塞主循环。

对于消行动画,一个简单的实现是:在消行时,不要立即删除该行并让上面的行掉下来。而是先将被消除的行标记为“闪烁”状态,在接下来的几帧中,交替用高亮色和正常色绘制这些行,然后再执行消除和下落逻辑。这需要扩展board数组的状态,或者维护一个额外的动画计时器列表。

6.4 代码结构与工程优化

当所有功能堆在一个main.cpp里时,代码会变得难以维护。良好的习惯是将代码模块化:

  • game.h/cpp:包含游戏状态(board,currentPiece,score等)和核心逻辑函数(canMove,lockPiece,checkLineClear,spawnNewPiece)。
  • render.h/cpp:包含所有绘图函数(drawBoard,drawPiece,drawUI,drawGameOver)。
  • input.h/cpp:包含输入处理函数(processInput)。
  • main.cpp:主函数和游戏循环。

使用头文件进行声明,在源文件中实现。合理使用constenum和命名空间,能让代码更清晰。

7. 调试技巧与常见问题

开发过程中,你肯定会遇到各种Bug。这里分享几个实用的调试技巧。

7.1 使用控制台输出进行调试

即使你的项目是窗口子系统,也可以在VS中通过AllocConsole()函数动态创建一个控制台窗口,用于输出调试信息。

#ifdef _DEBUG AllocConsole(); // 分配控制台 freopen("CONOUT$", "w", stdout); // 重定向标准输出 #endif // ... 你的游戏代码 printf("当前方块位置: (%d, %d)\n", currentPiece.posX, currentPiece.posY);

在Release构建时,通过条件编译移除这些代码。

7.2 常见的逻辑错误

  1. 数组越界:这是最常犯的错误。在访问board[y][x]时,务必确保yx在有效范围内[0, HEIGHT-1][0, WIDTH-1]。在碰撞检测中,对于boardY < 0(方块在隐藏区)的情况要特殊处理,不应访问棋盘数组。
  2. 旋转中心点不对:方块的旋转是围绕一个“轴心点”进行的。对于4x4矩阵,通常这个点是(1, 1)(2, 2)(取决于形状定义)。如果你的方块旋转后位置飘忽不定,检查旋转算法和参考点设置。
  3. 时间管理混乱:下落、按键重复都依赖时间。确保你的计时器(如GetTickCount)精度足够,并且时间差计算正确。避免在循环中使用Sleep过长时间导致界面卡顿。
  4. 内存泄漏:虽然这个项目很小,但如果你动态分配了内存(比如用new创建对象数组),记得在游戏结束或重新开始时delete

7.3 性能考量

对于俄罗斯方块,性能几乎不是问题。但养成好习惯很重要:

  • 避免在渲染循环中进行复杂计算:比如,不要在draw函数里频繁计算方块阴影位置。应该在方块移动或旋转时计算一次,然后缓存结果。
  • 双缓冲与批量绘制:EasyX默认是双缓冲的(initgraph的第三个参数可以设置),能有效避免闪烁。对于大量小方格的绘制,虽然fillrectangle很快,但如果未来扩展,可以考虑更高效的绘制方式。
  • 游戏循环的睡眠:使用Sleep控制帧率不够精确,且会释放CPU时间片。对于需要更精确计时的游戏,可以使用timeBeginPeriod提高定时器精度,或查询高性能计数器QueryPerformanceCounter

走到这一步,你应该已经拥有一个功能完整、手感不错的俄罗斯方块游戏了。从环境搭建到逻辑实现,再到细节打磨,这个过程几乎涵盖了一个小型2D游戏项目所需的所有核心技能点。我个人的体会是,最难的不是画方块或者消行,而是设计一个清晰、无歧义的状态管理系统,以及处理各种边界情况(比如旋转到墙边、新方块生成即死亡)。把这些都想清楚并实现后,再看那些复杂的商业游戏,你会发现它们无非是在更大的规模上重复着类似的设计模式。这个项目代码量不小,但每一行都有其意义,希望你在“爆肝”之后,不仅能收获一个游戏,更能收获面对更复杂项目时的从容和思路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询