简介:QT与C++实现的俄罗斯方块完整源代码,面向正在学习Qt桌面开发或经典游戏逻辑的初学者与进阶者。压缩包共12个文件,仅58KB,包含4个cpp源文件、3个h头文件、1个pro工程文件及png/jpg预览截图;cpp与h分离接口实现,配工程文件可直接用qmake构建。项目以Tetris游戏为主线,用QGraphicsView/QGraphicsItem绘制游戏区域、方块和网格,用QTimer控制下落节奏,并通过信号槽机制处理旋转按钮、碰撞检测与消行判断。各方块类型独立成类,主逻辑封装在专门控制器中,随机生成与行消除函数体现模块化设计;动画更新和事件驱动流程清晰,适合逐行研读。已有466人学习浏览,可从中掌握Qt GUI编程、C++面向对象设计、定时器调度和工程构建方法;资源体量小巧而结构完整,也可作为课程设计或二次开发起点。
1. 一份能跑的QT C++俄罗斯方块:从入门到理解图形界面游戏
如果你翻过不少QT教程,脑海里的QGraphicsView还停留在“可以画矩形”的认知,那这份QT C++俄罗斯方块源代码值得下下来拆一遍。它不是那种只有几百行的教学Demo,而是一个把经典游戏完整落地的工程:方块生成、旋转、碰撞检测、行消除、计分逻辑全部串起来了,截图、界面预览、源文件布局图都随包附带,对着代码学比对着视频学要踏实得多。适合两类人:一是刚学完C++和QT面向对象想找个完整项目练手的新手,二是被教程案例喂饱但没接触过QTimer驱动动画和QGraphicsItem绘制实战的人。看完你会明白一个游戏循环在QT里是怎么被信号槽和定时器撑起来的。
2. 项目全景与技术选型:QT的QGraphicsView为什么是这块游戏的正确底座
2.1 资源包内容:先看布局再动手
拿到这个压缩包,先别急着解压跑代码,按文件名把素材捋一遍会更有效率。包里除了源码,还带了三张关键的辅助图:捕获.JPG 是程序运行时的真实截图,界面预览.png 展示的是窗口布局和控件排布,源文件布局.png 则是整个工程的目录树。这三张图对新手特别友好,因为它把“正确答案”直接摆在你面前了——你写完代码跑出来的界面,应该和截图一致;如果类文件组织乱了,看一眼布局图就能对回原来的结构。
我的习惯是先看界面预览再碰代码,这样进入源码时心里已经有一张画面地图:哪里是游戏主区域,哪里是下一块预览区,哪里显示得分。很多初学者一上来就找main.cpp,结果被一堆文件绕晕,其实先花五分钟看图片,后面读代码会顺很多。
2.2 技术栈选型:为什么是QGraphicsView而不是QWidget
实现俄罗斯方块,单纯用QWidget和painterEvent也能画出来,但这份源码选择了QGraphicsView作为游戏画板,这个选择值得反复琢磨。QGraphicsView带来的是一整套“场景-视图-图元”三层架构:QGraphicsScene持有所有游戏元素,QGraphicsView负责渲染呈现,QGraphicsItem则代表每个可交互的方块格子。对于俄罗斯方块这种需要频繁增删大量矩形格子的游戏,这个架构天然适合。
对比一下就知道差距:QWidget方案里,每一次画面更新都要调用update()触发整个窗口重绘,方块一多性能就肉眼可见地掉帧;而QGraphicsScene管理的是一个图元列表,每个图元是独立对象,移动方块时只需要更新对应item的坐标,不需要重绘整个棋盘。源码里的网格线、已落下方块、当前活动方块,都各自以QGraphicsItem的形式存在,这在调试时也是个福利——你可以单步检查每个item的坐标值,而不是面对一整个painter事件。
另外一个务实原因是事件分发。QGraphicsView把鼠标和键盘事件先用scene坐标系统做了一层转换,对于要做点击旋转、拖拽落下的扩展玩法,直接重写scene的event处理方法就行,不必自己写坐标换算,省掉很多重复劳动。
2.3 目录结构与构建方式
从这个项目的源码布局来看,工程遵循了QT里最经典的“界面与控制分离”结构:启动入口main.cpp只负责实例化主窗口并进入事件循环,游戏逻辑全部收敛到Board类里,方块形状数据则抽象成Piece类。头文件(.h)与实现文件(.cpp)严格分开,每个类都保持单一职责,这刚好是C++面向对象设计里最值得学习的部分。
构建方式用的是qmake,这是QT传统工程的标准工具链。在终端里跑到源码目录执行:
qmake tetris.pro makeqmake会读取工程文件里的QT += core gui、TARGET、SOURCES等配置,自动生成对应平台的Makefile。需要说明的是,如果用的是QT Creator,直接打开.pro文件点运行即可;命令行方式则要保证qmake在当前PATH环境变量里。构建完成后,生成的二进制文件会放在Makefile指定的目标路径下,在Linux/Unix系统里通常就是当前目录的tetris可执行文件。
提示:如果你在多版本QT环境里切换过,qmake可能指向旧版本。执行qmake --version确认一下,避免版本不一致导致链接错误。
对于想二次开发的人,这个目录组织的可读性很重要:改游戏规则去Board类找,调整方块样式去Piece类找,动界面布局去MainWindow里找,视线不需要在几十个文件里乱跳。
3. 核心模块拆解:一个方块从生成到消行的完整生命周期
3.1 方块类型设计:用类封装形状、颜色和行为
俄罗斯方块一共有七种标准形状,每种形状的旋转规则和颜色都不相同。这个项目的Piece类用一组相对坐标点来描述形状,这是最直观也最容易扩展的做法。每个方块对象记录着自己当前四个小格子的相对位置,以及属于自己的颜色。
// piece.h —— 单个方块的形状与状态 class TetrisPiece { public: // 用坐标点集合描述形状,每个点相对piece原点的偏移 using Shape = QVector<QPoint>; TetrisPiece(); // 返回当前旋转状态下的形状坐标 Shape shape() const { return coords; } // 顺时针旋转:对每个点做 (x, y) -> (-y, x) 变换 void rotate() { for (QPoint &p : coords) { int x = p.x(); int y = p.y(); p.setX(-y); p.setY(x); } } QColor color() const { return mColor; } private: Shape coords; QColor mColor; };这里的核心是rotate函数里的坐标变换公式。顺时针旋转90度,本质上是把每个像素点绕原点旋转变换,数学表达式就是新的x等于负的旧y,新的y等于旧的x。这个变换有个特点:连续执行四次会回到初始状态,所以代码里不需要额外维护旋转状态,直接原地修改坐标即可。初次接触的人容易把旋转写成分段枚举,比如为每个方向准备一套坐标,那样代码会膨胀四倍,而且漏掉任何一个方向都会出bug。
使用坐标点而不是二维数组的另一个好处是碰撞检测简单。检测一个方块是否与边界或已填格子冲突,只需要把每个相对坐标叠加到方块当前位置上,再检查目标格子是否越界或已有内容即可,没必要遍历一个4x4矩阵。这个设计直接影响了后面的核心逻辑写法。
3.2 游戏主逻辑:Board类如何拆分职责
游戏的核心状态全部集中在Board类里,它维护了一个二维网格数组、当前活动方块、游戏是否开始的标记,以及计分数据。这个类继承了QGraphicsScene,因为游戏区域本身就是场景,键盘事件和绘制都由场景统一接管。
// board.h —— 游戏主逻辑承载类 class GameBoard : public QGraphicsScene { Q_OBJECT public: GameBoard(QObject *parent = nullptr); void startGame(); void setTimerInterval(int msec); // 外部调节下落速度 protected: void keyPressEvent(QKeyEvent *event) override; private slots: void moveDown(); // 定时器触发的下落动作 private: bool tryMove(const QPoint &newPos); // 尝试把当前方块移动到newPos bool hasCollision(const TetrisPiece &piece, const QPoint &pos); int removeFullRows(); // 消除满行,返回消除行数 void draw(); // 把网格和当前方块绘制到场景 int grid[ROW_COUNT][COL_COUNT]; // 记录已落下方块的网格 TetrisPiece curPiece; QPoint curPos; bool isStarted; };这段代码最值得学习的是把“游戏规则”和“界面呈现”揉进了同一个类却没有乱。grid负责逻辑,draw负责呈现,tryMove负责校验,moveDown负责定时推进。如果你刚开始接触游戏编程,可能会觉得一个类里既有数据又有界面不太符合“分层”思想,但俄罗斯方块这种单窗口小游戏,把强关联的状态放一起反而是最清晰的写法。真要拆成game engine和renderer两个大类,在这个体量下反而增加理解成本。
startGame时做的第一件事不是马上生成方块,而是把网格数组清零、把旧的图元从场景中移除。这个“重置状态再开始”的流程容易被新手忽略,导致连续玩两局时旧方块还残留在画布上。
3.3 绘制链路:从scene到item再到视图渲染
绘制逻辑的入口是draw函数。它把网格里每个非空格子映射成场景里的一个矩形图元,同时把当前活动方块也画上去。这里利用了QGraphicsScene对重复项的自动管理:新方块需要显示时,直接addItem;方块落定后,它自然变成网格的一部分,不需要额外操作。
// board.cpp —— 网格与方块的绘制逻辑 void GameBoard::draw() { QGraphicsRectItem *rect = nullptr; // 优先清掉上一帧的活动方块图元,避免重叠 for (QGraphicsItem *item : items()) { if (item->data(0).toInt() == 1) { removeItem(item); delete item; } } // 画网格中已固定的方块 for (int row = 0; row < ROW_COUNT; ++row) { for (int col = 0; col < COL_COUNT; ++col) { if (grid[row][col] != EMPTY) { rect = new QGraphicsRectItem(0, 0, CELL_SIZE, CELL_SIZE); rect->setPos(col * CELL_SIZE, row * CELL_SIZE); rect->setBrush(QBrush(gridColor[row][col])); rect->setData(0, 0); // 标记为固定方块 addItem(rect); } } } // 画当前活动方块 for (const QPoint &p : curPiece.shape()) { rect = new QGraphicsRectItem(0, 0, CELL_SIZE, CELL_SIZE); rect->setPos((curPos.x() + p.x()) * CELL_SIZE, (curPos.y() + p.y()) * CELL_SIZE); rect->setBrush(QBrush(curPiece.color())); rect->setData(0, 1); // 标记为活动方块 addItem(rect); } }这段代码里的setData是一个容易忽略但很重要的细节。它给图元挂了一个自定义整数标记,后续移动当前方块时,就能通过data(0)的值快速从场景里找出需要删除的旧图元,而不是误删已经固定的方块。如果你不用这个标记,draw函数执行两次就会出现“幽灵方块”——同一个位置叠了新旧两层图元,颜色还会变深。项目里用这个技巧保证每次重绘只保留最新状态,对应的更新策略是删除全部活动块图元左右,如果遇到方块平移频繁的场景,也可以改成只更新pos,但原始工程显然更看重代码直白程度。
QGraphicsView配合QGraphicsScene时,还有一个常见玄学问题:为什么画出来的方块不在期望的位置?原因多半是没有正确设置sceneRect。这个项目里,场景的原点对应棋盘的左上角,每个格子的像素尺寸是固定的CELL_SIZE,所以映射关系只要做一次乘法就行。看到坐标错位时,先检查是不是这里少乘了一个值。
4. 信号槽与碰撞检测:游戏交互从按键到状态变更的链路
4.1 QTimer驱动:游戏循环的骨架
俄罗斯方块没有复杂的物理引擎,它的“游戏循环”就是定时器反复触发同一个动作。项目里创建了一个QTimer,设置固定间隔,超时后发射timeout信号,连接的槽函数执行下落一步。这个机制把游戏的节奏感变成了一行配置:
// mainwindow.cpp —— 定时器初始化与信号槽连接 gameTimer = new QTimer(this); gameTimer->setInterval(500); // 每500毫秒下落一格 connect(gameTimer, &QTimer::timeout, board, &GameBoard::moveDown); gameTimer->start();setInterval的单位是毫秒,这里500表示半秒一格的初始速度。很多新手在这里翻车,把500当成秒,导致方块慢得像蜗牛爬,或者为了加速把间隔设置成50结果快到没法操作。调速的正确做法是保留一个基准速度变量,消行数越多就把间隔设得越小,而不是直接硬编码一个极端值。
QTimer驱动模式还有个容易被忽略的点:它不保证精确的定时精度,如果主线程在短时间内处理大量绘制操作,timeout信号的发射可能会被挤到后面。在俄罗斯方块这个体量下感受不到,但如果以后你拿这套架构去做更复杂的游戏,就得考虑把逻辑计算和渲染分离到不同线程。这里用单线程就够了,因为每次绘制只有最多一两百个矩形,性能压力很小。
moveDown槽函数内部其实不是无脑向下移动,它会先调用tryMove试探下一步位置是否合法,如果不合法就说明当前方块已经落定,这时把它的形状写进grid数组,然后检查消行、生成新方块。这个“先试探再落定”的模式是整个游戏逻辑的枢纽,也是接下来理解碰撞检测的关键入口。
4.2 键盘事件处理:把按键变成旋转和位移
游戏操作依赖keyPressEvent事件重写,所有按键动作都集中在游戏场景类里。方向键控制左右移动,上方向键旋转,下方向键加速下落,空格直接硬降到最低点。每个按键分支都调用tryMove或者rotate,然后更新画面:
void GameBoard::keyPressEvent(QKeyEvent *event) { if (!isStarted) return; switch (event->key()) { case Qt::Key_Left: tryMove(curPos + QPoint(-1, 0)); break; case Qt::Key_Right: tryMove(curPos + QPoint(1, 0)); break; case Qt::Key_Up: curPiece.rotate(); if (!hasCollision(curPiece, curPos)) { draw(); } else { // 旋转后撞墙则回退,避免转出一个非法状态 curPiece.rotate(); curPiece.rotate(); curPiece.rotate(); } break; case Qt::Key_Down: moveDown(); break; case Qt::Key_Space: // 硬降,循环下移直到触底 while (tryMove(curPos + QPoint(0, 1))) { // 每步都更新画面,实现连续下落效果 draw(); } moveDown(); break; default: QGraphicsScene::keyPressEvent(event); } }旋转处理这里有个细节值得品:执行旋转后马上做了碰撞回退。因为某些方块在靠墙时旋转会越过边界,如果不回退,方块就会“嵌”进墙里。回退的方式是连续旋转三次恢复原状,这利用了旋转公式四倍回到原点的性质,比起额外保存旋转前坐标要省事。硬降的写法也很有代表性,用while循环不断试探往下移动,直到失败那一刻,再调用一次moveDown让方块正式落定。
4.3 碰撞检测与行消除:把边界条件算清楚
tryMove是所有移动操作的唯一通道,它内部先调用hasCollision判断目标位置是否违规,合法才更新坐标。这个收口设计很重要,它保证没有任何移动能绕过碰撞检测,你不需要担心某个快捷键漏写了校验。hasCollision的判断范围包括左右下三面边界,以及网格里已经被占用的格子:
bool GameBoard::hasCollision(const TetrisPiece &piece, const QPoint &pos) { for (const QPoint &p : piece.shape()) { int nx = pos.x() + p.x(); int ny = pos.y() + p.y(); if (nx < 0 || nx >= COL_COUNT) return true; // 左右越界 if (ny >= ROW_COUNT) return true; // 触底 if (ny >= 0 && grid[ny][nx] != EMPTY) return true; // 撞到已固定方块 } return false; }这里有意放过了ny < 0的情况,也就是允许方块刚生成时有一部分在棋盘顶部的上方。这个设定是俄罗斯方块的通行做法,给新方块进入画面留出缓冲空间,如果你把ny < 0也判为碰撞,游戏开始在生成第一个方块时就会直接判负。
行消除的逻辑稍加变化。从最底层往上扫描,只要发现某一行全部非空,就把它上面的所有行各自下移一格,然后清空最顶行。消除一行后还要原地再看一次当前行,因为上方落下来的行可能是满的。
int GameBoard::removeFullRows() { int removed = 0; for (int row = ROW_COUNT - 1; row >= 0; --row) { bool full = true; for (int col = 0; col < COL_COUNT; ++col) { if (grid[row][col] == EMPTY) { full = false; break; } } if (full) { // 当前整行下移一格 for (int r = row; r > 0; --r) { for (int c = 0; c < COL_COUNT; ++c) grid[r][c] = grid[r - 1][c]; } // 置空最顶行 for (int c = 0; c < COL_COUNT; ++c) grid[0][c] = EMPTY; ++row; // 因为上面落下来一行,原地再检查一次 ++removed; } } return removed; }这段代码里++row这个操作是我觉得整份源码里最见功力的地方。如果不原地再查一次,连续出现两行同时满时,第二行会被漏判。很多人写消行时只消了一半,问题就出在这里。消除完成后返回的行数可以用来计算得分:消除一行记一分,连续多行可以设计加成分。
5. 避坑指南:编译运行调试中我踩过的五个坑
5.1 界面黑屏或方块全部挤在左上角
现象:程序跑起来,游戏区域一片黑,方块全部出现在窗口左上角巴掌大的地方,网格线也对不齐。
原因:QGraphicsScene默认的原点在(0,0),但如果你没有显式设置sceneRect,视图会按照所有item的内容范围自动缩放,导致坐标系和棋盘网格对不上。另一个常见叠加原因是把网格坐标直接当像素坐标用了,没乘格子边长。
解决:在构造场景时显式调用setSceneRect(0, 0, COL_COUNT * CELL_SIZE, ROW_COUNT * CELL_SIZE),并确认所有item的坐标都是“列索引 * CELL_SIZE”而不是直接用列号。如果之后发现方块位置偏离半格,多半是pos设置时把原点多算了一格。
5.2 qmake成功了,一运行就崩溃
现象:qmake和make都顺利走完,双击生成的exe或者从命令行启动,窗口都没弹出来就崩了。
原因:最常见的是QGraphicsItem对象new出来了但没有addItem到scene里,或者QTimer连接时用了旧式的字符串语法,信号槽参数类型不匹配,直到事件循环触发时才炸。尤其是前者,QT文档里明确写了QGraphicsItem必须依附于scene,否则就是一个悬空对象,任何对它的操作都可能段错误。
解决:所有图元创建后立即调用scene->addItem();connect统一用&QTimer::timeout这类新式函数指针写法,让编译期替你做参数检查。如果还是崩,用调试器看堆栈,我踩过的一次就是卡在绘制函数里访问了一个空指针引用。
5.3 方块落到底之后还能左右移动,甚至嵌进已填充的行
现象:方块明明已经堆到底了,按左右方向键依然会平移,甚至会穿进已经固定好的方块堆里。
原因:tryMove函数只校验了纵向移动,左右平移分支另写了一套移动逻辑,没有复用hasCollision。两边逻辑不一致是这类小游戏最容易埋雷的地方。
解决:让左、右、下三种移动全部走同一个入口tryMove,这个入口内部统一调用hasCollision。hasCollision里把左右边界和下边界、已占用格子的判断一次性算完,不要在三个地方各写一份边界条件。我一般还会加一行调试输出,在移动失败的瞬间把目标坐标打出来,一眼就能看出是哪个方向的判断漏了。
5.4 中文目录下qmake构建失败
现象:项目放在“某个盘/我的文档/项目/tetrix”这种带中文的路径下,make时报错找不到头文件或目标文件,拷到纯英文路径就好了。
原因:Windows下用MinGW工具链时,make生成的依赖规则对中文路径处理不友好,路径串里的中文编码和反斜杠拼接容易断链。这个问题在旧版本的QT Creator里尤其频繁。
解决:所有QT工程一律放在纯英文路径下,路径中也不要带空格。这个习惯后来帮我避开了大量构建玄学问题,毕竟谁都不想在排查了一晚上编译器错误之后,最后的解决方案是改文件夹名。
5.5 消行之后上面的方块没有掉下来,像悬空一样
现象:凑满一行后被清掉了,但头顶上一堆方块没有补齐空白,网格里出现一条悬空的裂痕。
原因:消行时只做了“把当前行清零”,没有把该行上方的所有内容整体下移一行。这在画面上的表现就是行确实消失了,但上面的方块留在原地没跟着挪下来。
解决:严格按“从下往上扫描,满行就把上面的行逐行下移一格,再把顶行置空”的顺序来写。注意下移必须从目标行开始往上遍历,而不是从上往下,否则会把下面的数据覆盖掉。写完后用连续两行同时满的用例来验证,能暴露漏判问题。
6. 二次开发思路:从复刻经典到做一个有自己风格的版本
6.1 改造方向一:增加下一个方块预览
当前工程只显示活动方块,下一个方块的信息藏在逻辑里。实现预览功能并不难:在Piece类里增加一个获取形状和颜色的接口,MainWindow区域右侧放置一个独立的QGraphicsView,每次生成新方块时把下一块的形状画进去。只需要在生成逻辑里多保存一份“预备方块”对象,并在消行后触发的更新函数里同步刷新预览场景。这个改动能让你更早接触“多视图共享同一份数据”的QT设计模式。
6.2 改造方向二:引入计分板与等级变速
现有计费逻辑完成度已经足够跑通,但节奏是固定的。你可以把下落间隔和消除行数绑定:每消满十行,把计时器间隔减少50毫秒,同时更新界面上的等级数字。实现上只需在removeFullRows的返回值基础上,做一个累加判断,然后调用setTimerInterval把新的间隔写到QTimer上。要注意设置一个最低间隔上限,比如100毫秒,否则几十级之后游戏快到完全没法操作,瞬间从方块游戏变成抽风游戏。
6.3 改造方向三:加入暂停与重新开始
暂停功能适合检验你对事件驱动模型的理解。加一个bool变量isPaused,在keyPressEvent里拦截P键,暂停时调用gameTimer->stop(),恢复时再start()。重新开始则要求初始化全部网格数据和场景图元,注意清理残留的item。
从那以后,我每次拿到一个QT小游戏源码,第一件事不是急着改功能,而是先画一遍“按键到状态变更”的链路图,把tryMove、hasCollision、draw这条主链标注出来,再动手。这次拆QT C++俄罗斯方块源码,我按这条路线走,半天就把整个工程吃透了,后面加预览、调速都顺顺利利,没再被碰撞边界的边界条件绊住脚。这份源码包里还带了界面截图和布局图,下载后对照着文件名看,能少走不少弯路。希望帮到你。
本文还有配套的精品资源,点击获取