☰
QT C++俄罗斯方块源码剖析:QGraphicsView与状态机实现
2026/10/18 14:34:11 网站建设 项目流程

简介:QT C++俄罗斯方块源代码是一份轻量而完整的开源学习项目,面向希望入门QT GUI编程或巩固C++游戏开发基础的开发者。工程通过QGraphicsView与QGraphicsItem绘制游戏区域与方块,用QTimer控制下落节奏,并以信号槽机制响应按键操作,展示了从界面搭建到游戏循环的完整流程。压缩包共12个文件,包括4个cpp源文件、3个头文件、1个pro工程文件及若干界面预览图片,整体仅58KB,结构精简便于阅读和二次修改。代码中还包含随机方块序列生成、消行检测、碰撞判断等核心逻辑,并采用头文件与实现文件分离的方式组织类,适合逐模块分析。目前已有466人学习参考,无论是课程设计还是QT框架实战入门,都能从中获得面向对象设计与事件驱动编程的具体范例。

1. 拿到QT C++俄罗斯方块源码:它给的不是界面,而是一套方块状态机

一提到QT C++俄罗斯方块,不少人的第一反应是去看绘图和动画代码,但真正决定手感、决定你能不能改成不同消行得分、不同掉落速度的,是核心Board类里的方块坐标表和它背后的状态机。这份源码恰好把QT的QGraphicsView、QTimer、信号槽串成一个完整的2D游戏闭环:界面不是PNG贴图,而是用图形场景实时绘制;旋转不是贴图翻转,而是坐标变换;下落不是靠sleep,而是靠定时器事件驱动。适合刚学完QT基础、想完整看一个QGraphicsView程序怎么组织的新手,也适合有经验、想快速拿一个可改的俄罗斯方块骨架来二次开发的从业者——这份资源解决的是"从零搭一个QT游戏"的问题,不是教你QPainter画圆。

2. 类结构拆解:Board、Piece与View三层分工,游戏主逻辑全在Board

2.1 从tetrix参考实现出发:三层类的职责边界

打开源码先别急着编译,第一步是确认它是不是仿QT官方tetrix示例的结构。大多数这类源码长这样:一个继承QGraphicsWidget的Board类负责游戏区网格和碰撞,一个TetrixPiece结构体承载形状数据,一个View类负责绘制和菜单栏预览。你可以理解为"数据、逻辑、显示"三层分开:

  • Piece:只保存当前是什么形状、四个格子的坐标、颜色索引,不含任何游戏规则。
  • Board:游戏主逻辑的核心,持有网格状态数组、当前方块、分数、等级,处理按键、定时器、碰撞、消行。
  • View/Window:负责把Board的画布挂到窗口中,搭菜单栏、Preview区域,转发信号更新分数标签。

这种拆法最大的好处是方便替换界面。比如你想做QML版,只需要保留Piece和Board,把View换掉即可。这个源码的工程布局里能看到头文件和实现文件分离,源文件布局.png会把每个类的文件归属标清楚,照着它定位类比一层层翻目录快得多。

2.2 方块坐标表的定义:为什么用静态数组而不是运行时算

俄罗斯方块有7种基本形状,每种形状又允许左右旋转。最常见的、也是这份源码里采用的方案,是把每种形状的初始坐标写死在一个三维数组coordsTable[8][4][2]里:第一维是形状编号,第二维是4个格子,第三维是x和y偏移。后面的旋转动作用坐标变换公式处理,而不是把四种旋转角度都预生成好。

// tetrixpiece.h —— 形状枚举与坐标表定义 enum TetrixShape { NoShape, ZShape, SShape, LineShape, TShape, SquareShape, LShape, MirroredLShape }; const int coordsTable[8][4][2] = { { { 0, 0 }, { 0, 0 }, { 0, 0 }, { 0, 0 } }, // NoShape { { 0, -1 }, { 0, 0 }, { -1, 0 }, { -1, 1 } }, // ZShape { { 0, -1 }, { 0, 0 }, { 1, 0 }, { 1, 1 } }, // SShape { { 0, -1 }, { 0, 0 }, { 0, 1 }, { 0, 2 } }, // LineShape { { -1, 0 }, { 0, 0 }, { 1, 0 }, { 0, 1 } }, // TShape { { 0, 0 }, { 1, 0 }, { 0, 1 }, { 1, 1 } }, // SquareShape { { -1, -1 }, { 0, -1 }, { 0, 0 }, { 0, 1 } }, // LShape { { 1, -1 }, { 0, -1 }, { 0, 0 }, { 0, 1 } } // MirroredLShape };

这个坐标表采用的约定是数学坐标系:y轴向上为正

,所以{0,-1}表示中心点上方一格。注意SquareShape是唯一不会旋转的形状,坐标变换函数里会对它做特判。 如果动手改这个数组,比如把ZShape的坐标从{-1,1}改成{-1,0},你会发现方块贴地时的形状完全变了——这就是"形状数据驱动一切"的含义。

2.3 Board类的成员划分:状态、网格与信号槽接口

Board类是整个项目的黑匣子,外部界面只通过几个公有方法和信号跟它交互。从头文件声明能一眼看出它的设计思路:游戏状态变量、网格数组和方法声明分块排列,private slots区域专门放定时器事件,signals区域放对外通知。

// tetrixboard.h —— Board类核心接口 class TetrixBoard : public QGraphicsWidget { Q_OBJECT public: TetrixBoard(QGraphicsWidget* parent = nullptr); void startGame(); void stopGame(); void clearBoard(); void setNextPiece(TetrixPiece* nextPiece); protected: void keyPressEvent(QKeyEvent* event) override; void timerEvent(QTimerEvent* event) override; void paint(QPainter* painter, const QStyleOptionGraphicsItem* option, QWidget* widget) override; private: TetrixShape& shapeAt(int x, int y); bool tryMove(const TetrixPiece& newPiece, int newX, int newY); int removeFullLines(); void pieceDropped(); int boardX; int boardY; int score; int level; int gameTimer; TetrixPiece currentPiece; TetrixPiece nextPiece; };

看到Q_OBJECT宏就要知道这个类是支持信号槽的,startGame()、stopGame()是给菜单栏调用的入口,pieceDropped()是方块落地后的处理链入口。shapeAt(x, y)返回的是引用,这意味着你既能读方块,也能直接往里写方块,消行时靠它快速做整行数据搬迁。官方参考实现这里的命名略微不同,但结构基本一致。阅读时建议先画一张调用链草图:keyPressEvent→tryMove→update→paint,比逐行读代码高效。

3. 绘制机制拆解:QGraphicsView坐标系、旋转原理与重绘流程

3.1 为什么用QGraphicsView而不是QWidget

做2D游戏,很多初学者第一反应是继承QWidget重写paintEvent,但那意味着自己管理坐标变换和局部刷新,做俄罗斯方块够用,却不利于扩展。这份源码选用QGraphicsView/QGraphicsWidget体系,核心原因是场景树:Board本身就是场景里的一个节点,网格、方块、预览区域都可以作为独立的graphics item挂载,后续加特效、动画都不用推翻重写。另一个原因是QGraphicsItem体系天然支持变换矩阵,旋转、缩放、拖动都能靠框架完成。缺点是学习曲线稍陡,你得先理解"场景坐标"与"item局部坐标"的关系,否则画出来的东西永远错位。

3.2 场景坐标的换算:格子尺寸、棋盘边界与坐标翻转

棋盘宽10格、高20格,每格像素尺寸是SquareHeight(源码里常见值为20)。Board的paint函数里,所有绘制都通过x * SquareHeight把逻辑坐标映射成像素坐标。注意绘制顺序是网格线在前、底下的方块其次、当前方块最后,这样当前方块总是浮在已落定方块之上,视觉层次清楚。

// tetrixboard.cpp —— paint函数的核心片段 void TetrixBoard::paint(QPainter* painter, const QStyleOptionGraphicsItem* option, QWidget* widget) { Q_UNUSED(option); Q_UNUSED(widget); painter->save(); // 第一步:平铺背景色,避免透明叠加导致的重影 painter->fillRect(boundingRect(), backgroundColor); // 第二步:画网格线,横竖线各画到边界为止 painter->setPen(gridLinePen); for (int i = 0; i <= BoardWidth; ++i) { painter->drawLine(i * SquareHeight, 0, i * SquareHeight, BoardHeight * SquareHeight); } for (int j = 0; j <= BoardHeight; ++j) { painter->drawLine(0, j * SquareHeight, BoardWidth * SquareHeight, j * SquareHeight); } // 第三步:循环画已落定的方块 for (int x = 0; x < BoardWidth; ++x) { for (int y = 0; y < BoardHeight; ++y) { TetrixShape shape = shapeAt(x, y); if (shape != NoShape) { painter->setBrush(colorForShape(shape)); painter->drawRect(x * SquareHeight + 1, y * SquareHeight + 1, SquareHeight - 2, SquareHeight - 2); } } } // 第四步:画当前正在下落的方块 if (currentPiece.shape() != NoShape) { for (int i = 0; i < 4; ++i) { int x = boardX + currentPiece.x(i); int y = boardY - currentPiece.y(i); if (y >= 0) { painter->setBrush(colorForShape(currentPiece.shape())); painter->drawRect(x * SquareHeight + 1, y * SquareHeight + 1, SquareHeight - 2, SquareHeight - 2); } } } painter->restore(); }

注意第四步里的boardY - currentPiece.y(i),这是全项目最容易被绕晕的一行。坐标表里y向上为正,而屏幕坐标y向下为正,把currentPiece.y(i)取反用来补正,正好把数学坐标转换成屏幕坐标。drawRect参数里加减1、把方块尺寸缩到SquareHeight - 2,是给每个小方块留出1px间距,否则相邻方块会连成一片看不出形状。完整填充能明显区分出每一块,推荐用暗色网格线。

3.3 颜色配置与当前方块的视觉高亮

colorForShape函数是绘制体系里的一个隐藏入口:它用一个静态颜色数组完成形状到颜色的映射。这个函数只依赖形状枚举值,与Board状态无关,所以SideBar预览区也能调用它,保证游戏区和预览区的颜色完全一致。

// tetrixboard.cpp —— 颜色映射函数 QColor TetrixBoard::colorForShape(TetrixShape shape) { static const QColor colors[8] = { QColor(0x00, 0x00, 0x00), // NoShape,黑色底 QColor(0xCC, 0x66, 0x66), // ZShape QColor(0x66, 0xCC, 0x66), // SShape QColor(0x66, 0x66, 0xCC), // LineShape QColor(0xCC, 0xCC, 0x66), // TShape QColor(0xCC, 0x66, 0xCC), // SquareShape QColor(0x66, 0xCC, 0xCC), // LShape QColor(0x65, 0x93, 0x21) // MirroredLShape }; return colors[int(shape)]; }

颜色值固定用十六进制写在代码里,这是我个人比较推荐的做法:集中管理和排查都方便,想调整视觉风格只需要改这一处。以后的扩展中,如果想加入"消除提示闪烁效果",可以在这里额外增加一个灰度数组,不需要碰paint主流程。

4. 游戏循环搭建:QTimer驱动下落、按键响应与信号槽联动

4.1 QTimer作为游戏心跳:interval参数与停止时机

俄罗斯方块本质上是一个"每过一小段时间,方块自动下落一格"的事件循环。这份源码里用的是QBasicTimer或QTimer::start配合timerEvent处理,我见到的多数移植版本用QBasicTimer居多,因为它的开销不依赖事件循环的具体对象。节奏控制的关键点:start(1000 / level)——这里的level不是游戏等级而是下落间隔,等级越高,间隔越短,下落越快。

// tetrixboard.cpp —— 游戏开始与定时器启动 void TetrixBoard::startGame() { if (gameTimer != 0) { killTimer(gameTimer); gameTimer = 0; } clearBoard(); score = 0; level = 1; emit scoreChanged(score); emit levelChanged(level); newPiece(); gameTimer = startTimer(1000 / level); }

给startTimer传入的间隔单位是毫秒,1000 / level意味着10级时每步只给100ms,这个强度对大多数玩家来说快到几乎没法操作。启动前必须先清空旧定时器,否则连续点两次"新游戏"会出现两个心跳叠发,方块下落速度翻倍。常见做法是像这段代码一样,在启动时先killTimer再startTimer。游戏结束后的收尾也有讲究:在stopGame()里要记住把定时器kill掉,否则游戏结束了方块还在自动下落,这是新手最容易漏掉的一步。

4.2 按键事件处理:方向键映射与规则细节

按键逻辑主要集中在keyPressEvent:Left和Right调tryMove尝试水平移动,Up调rotatedLeft尝试旋转,Down让方块加速下落,Space直接把方块落到底。注意这里"尝试"二字——每次按键都只是提出目标位置,能不能落过去完全由碰撞检测函数决定。

// tetrixboard.cpp —— 按键响应与方块移动 void TetrixBoard::keyPressEvent(QKeyEvent* event) { if (gameTimer == 0) { return; // 游戏未开始,忽略所有按键 } switch (event->key()) { case Qt::Key_Left: tryMove(currentPiece, boardX - 1, boardY); break; case Qt::Key_Right: tryMove(currentPiece, boardX + 1, boardY); break; case Qt::Key_Down: if (!tryMove(currentPiece, boardX, boardY - 1)) { pieceDropped(); } break; case Qt::Key_Up: tryMove(currentPiece.rotatedLeft(), boardX, boardY); break; case Qt::Key_Space: dropDown(); break; default: QGraphicsWidget::keyPressEvent(event); break; } }

Qt::Key_Down的加速逻辑值得玩味:它先尝试下移一格,如果移不动就立即执行pieceDropped(),把方块锁定到网格里。这意味着连续快按Down键会被自动转换成快速落底,手感不会拖泥带水。dropDown()内部的实现通常是一个循环,一直tryMove到撞墙为止,然后落底。这里有个容易被忽略的细节:keyPressEvent里没有调用update(),重绘动作全在tryMove和pieceDropped内部完成。我见过一些改造版本到处调用update(),结果画面闪烁不说,性能还变差。

4.3 信号槽通信:分数、等级与预览区的联动

Board类通常暴露三个信号:scoreChanged(int)、levelChanged(int)、linesRemovedChanged(int),窗口端负责把信号连接到QLabel的setNum槽上。这种解耦让Board本身完全不关心分数显示在哪里,以后改成QML界面也只需要重新连接信号槽即可。

// mainwindow.cpp —— 信号槽连接示例 connect(board, &TetrixBoard::scoreChanged, scoreLabel, &QLabel::setNum); connect(&gameTimer, &QTimer::timeout, board, &TetrixBoard::gameStep);

信号槽的连接时机放在窗口构造函数里,而不是startGame()中。原因很简单:信号槽连接只需要建立一次,如果每次开始游戏都重新connect,同一信号会触发多个槽函数——这是分数跳变的常见来源。彻底理解这套机制之后,你能把游戏规则和界面完全分开,哪怕把菜单栏整个替换掉,Board类一行都不用改。

5. 碰撞检测与消行动画:五个踩坑记录与排查思路

5.1 碰撞检测的tryMove:越界与重叠的双重判断

tryMove是整份源码里最核心的函数,既要判断新位置有没有撞到左右墙壁,也要判断目标格子有没有被已落定方块占据。它的返回值是bool,外界把它当成"能不能移动"的唯一依据。落地方块的逻辑也依赖它:当它返回false时,说明当前方块无法再往下走了。

// tetrixboard.cpp —— 碰撞检测核心逻辑 bool TetrixBoard::tryMove(const TetrixPiece& newPiece, int newX, int newY) { for (int i = 0; i < 4; ++i) { int x = newX + newPiece.x(i); int y = newY - newPiece.y(i); if (x < 0 || x >= BoardWidth) { return false; // 超出左右边界 } if (y < 0 || y >= BoardHeight) { return false; // 超出上下边界 } if (shapeAt(x, y) != NoShape) { return false; // 目标格子已被占用 } } currentPiece = newPiece; boardX = newX; boardY = newY; update(); return true; }

边界条件要特别注意:BoardWidth是10,合法的格子坐标是0到9,所以判断条件是x >= BoardWidth而不是x > BoardWidth。 同理y边界用y >= BoardHeight判断是否触底。三个坐标数组的运算里,newY - newPiece.y(i)再次出现坐标翻转,这一行如果漏掉减号,方块的落点会整体上移一格,视觉上悬空。在实际调试时,建议在tryMove入口加一行qDebug() << newX << newY,确认每次按键的意图和Board状态是否一致。

5.2 消行与数据搬迁:从下往上扫描的必然性

消行的基本思路是逐行检查名称:如果一行10个格子全部非空,就把上面所有行向下搬一格,然后在顶部补一行空行。这段数据搬迁看起来简单,实际写的时候很容易犯下标越界错误,尤其是从下往上扫描时,偏移处理不对会把整行数据复制到错误的位置。

// tetrixboard.cpp —— 完整的消行逻辑 int TetrixBoard::removeFullLines() { int numFullLines = 0; for (int y = BoardHeight - 1; y >= 0; ) { bool lineIsFull = true; for (int x = 0; x < BoardWidth; ++x) { if (shapeAt(x, y) == NoShape) { lineIsFull = false; break; } } if (lineIsFull) { ++numFullLines; // 把当前行上方所有行向下搬一格 for (int yy = y - 1; yy >= 0; --yy) { for (int xx = 0; xx < BoardWidth; ++xx) { setShapeAt(xx, yy + 1, shapeAt(xx, yy)); } } // 顶部补一行空行 for (int xx = 0; xx < BoardWidth; ++xx) { setShapeAt(xx, 0, NoShape); } // 不执行y--,继续检查当前y(因为上方内容已下沉) } else { --y; } } return numFullLines; }

这段代码的循环结构比较有意思:它不是每次循环固定递减,而是利用for循环体的自增机制手动控制步进。消掉一行后,上面的内容沉到当前行,所以需要再检查一遍同一个y,这也是不着急让y往下走的原因。判断lineIsFull时一旦遇到NoShape就立刻break,节省了完整的10格扫描。

5.3 五个高频坑:现象、原因与解决

坑一:方块落地后"粘"在边界,按方向键没反应

现象:方块贴左墙后,按Left没反应,按Right可以移动;落地后画面不刷新,但下一块出现时又正常。
原因:tryMove检查边界条件用x >= BoardWidth,而boardX在初始化时被设成了BoardWidth / 2,当方块旋转后左侧格子x坐标变成负数时,没考虑往右平移补偿。
解决:在rotatedLeft/rotatedRight后面补一段循环,若旋转后越界,自动向相反方向平移,直到完全落在棋盘内。

坑二:方块旋转时突然"跳"出棋盘外

现象:T形方块贴壁旋转时,有一部分格子瞬间出现在棋盘外,随后整块消失。
原因:旋转函数只交换坐标,没做边界检测;tryMove在旋转后调用时检测到越界返回false,但有些实现里旋转前先把原位置清空了,导致方块视觉上消失。
解决:旋转必须走tryMove尝试,成功才更新位置。如果第一次尝试失败,再尝试左右平移1格后再旋转,这就是大家常说的"踢墙"。

坑三:连续消行后得分翻倍,甚至出现负数

现象:一按"新游戏"分数就变成几千,消两行加了4000分。
原因:信号槽连接重复建立了多次,scoreChanged信号触发了多个槽函数,分数被加了两遍。
解决:把所有connect语句挪到构造函数中,在startGame里只重置分数数据,不重新连接信号槽。

坑四:界面闪烁,尤其是预览区

现象:方块下落过程中整个窗口快速闪烁,图形预览区域最严重。
原因:Board窗口的paint中用了update()触发整个Board区域重绘,没有利用QGraphicsView的局部重绘机制。
解决:把背景色和网格线缓存进一个QPixmap,paint里先贴图再只画变化区域,并避免一次性调用多个update()。

坑五:所有按键都无效,鼠标点击后才恢复

现象:启动程序后,方向键完全没反应,鼠标在棋盘上点一下后又正常。
原因:QGraphicsWidget默认没有获得键盘焦点,按键事件全被系统吞噬;焦点策略未设置成Qt::StrongFocus。
解决:在Board构造函数中设置setFocusPolicy(Qt::StrongFocus),并在窗口显示时手动调用board->setFocus()。

6. 构建与扩展:把源码跑起来的三步走与调试技巧

拿到源码包后,推荐从三处开始动手验证。第一步,打开源文件布局.png对照工程结构,确认头文件和源文件的对应关系,标记出Board、Piece、View三类文件。第二步,编译运行,确认界面能出来再聊代码逻辑;第三步,做一次"冒烟测试"——启动游戏、按方向键、消一行,确认基础链路通了。如果工程附带.pro文件,执行顺序是qmake生成Makefile,再make或make debug,最后运行可执行文件。我习惯先在命令行跑一版,确认无编译错误后再用IDE打开,这样出了问题能更快判断是环境问题还是代码问题。

调试俄罗斯方块有一种非常有效的土办法:在tryMove和pieceDropped里临时加qDebug()打印当前方块的形状、boardX、boardY,以及碰撞原因的标记。别小看这招,比起断点调试,它能帮你快速建立对游戏状态的把握感。另一种方法是把startTimer(1000 / level)的间隔临时改成3000,让方块慢慢落下,观察每一步的坐标变化是否合理。最常见的拓展方向有三个:一是加"下一个方块"预览区,这在SideBar类里已经留好了接口;二是加音效,用QSoundEffect配合信号触发;三是加游戏暂停功能,用killTimer和startTimer切换即可。其中预览区的改动性价比最高——只要把setNextPiece的连接补上,再给SideBar加一个update()重绘,就能做出类似商业版的效果。

这套源码我拆过不少次,每次在新环境配置QT时,都会强制自己先跑一遍冒烟测试再动手改代码。养成这个习惯后,我再也没有因为环境问题浪费过一整天。希望你也能从这份源码里找到自己需要的那个关键点。

本文还有配套的精品资源,点击获取

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

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

立即咨询