简介:这份QT C++俄罗斯方块源代码面向初学者与有经验的开发者,用于学习QT框架与C++游戏开发。项目以QT的QGraphicsView、QGraphicsItem绘制游戏区域与方块,用QTimer控制下落节奏,并通过信号与槽机制处理旋转、移动等事件,同时展示面向对象设计、碰撞检测、得分计算与行消除等核心逻辑,是理解事件驱动编程与模块化设计的实用范例。压缩包共12个文件,包含4个cpp实现文件、3个h头文件、1个pro工程文件,以及png、jpg截图和user配置,整体约58KB,结构清晰,便于直接编译运行与逐模块研读。目前已有465人学习下载。通过分析源码,读者可掌握QT界面绘制、定时器动画、类职责划分与qmake构建流程,并积累游戏循环与状态更新的排错经验,适合作为QT与C++进阶练习的参考项目。
1. 从一份 QT C++ 俄罗斯方块源代码说起:它到底能跑出什么
很多人第一次拿到 QT C++ 俄罗斯方块源代码.zip,脑子里想的是「编译一下就能玩」,结果双击 .pro 文件之后发现 Qt Creator 没反应,或者构建完弹出一堆 undefined reference。这份代码的价值不在于俄罗斯方块本身有多难,而在于它是一个把 C++ 基础、Qt 事件循环、QPainter 绘图、QTimer 定时器、信号槽机制全部串起来的完整小项目。俄罗斯方块看着简单,但真正写起来,方块旋转的坐标变换、消行后的数组搬移、下落速度随等级变化、键盘长按的重复触发,每一个点都能让新手卡半天。这份源代码适合两类人:一类是刚学完 C++ 基础语法、想找一个能跑起来的小游戏来练手的;另一类是想学 Qt 桌面开发、但不想一上来就啃文档的。它不涉及网络、不涉及数据库,所有逻辑都在本地跑,拿来拆解架构、改参数、加功能都合适。下面我会按「拿到代码后怎么跑通 → 核心模块怎么拆 → 参数怎么调 → 坑在哪 → 怎么改出自己的版本」这条线走一遍。
2. 把 QT C++ 俄罗斯方块源代码跑起来:环境、构建与第一个可玩版本
2.1 Qt 版本选型与安装路径的取舍
拿到源代码之后第一件事不是打开代码看,而是确认 Qt 版本。Qt 5 和 Qt 6 在 API 上有不少差异,尤其是 QPainter、QWidget 相关的部分,Qt 6 移除了一些废弃接口。如果你拿到的代码里用了QRegExp、QTextCodec这类类,那基本可以判断是 Qt 5 时代的产物。我一般建议先用 Qt 5.15.2 跑一遍,这是 Qt 5 系列最后一个 LTS 版本,兼容性最好,网上能查到的资料也最多。安装的时候注意勾选组件:MinGW 编译器、Qt Creator、Qt 5.15.2 的 Desktop 组件,这三个是必须的。如果你用 MSVC 编译器,还需要装 Visual C++ Redistributable 和对应的 Windows SDK,否则链接阶段会报找不到库。安装路径不要带中文和空格,这是血泪经验,Qt 的构建系统对路径里的特殊字符处理一直不太稳定。
安装完成后打开 Qt Creator,点「打开文件或项目」,选中 .pro 文件。如果项目里没有 .pro 而是 CMakeLists.txt,那说明是 CMake 构建的,Qt Creator 也能直接打开。打开之后先别急着点运行,看一下左下角的构建套件(Kit)选的是不是 Desktop Qt 5.15.2 MinGW 64-bit。如果显示灰色或者有感叹号,说明套件没配置好,去「工具 → 选项 → 构建和运行」里检查编译器路径。
2.2 构建报错的常见类型与逐条排查
构建阶段最常见的报错有三类。第一类是fatal: cannot mix incompatible Qt library (version 0x...) with this library,这个报错的意思是链接时混用了不同版本的 Qt 库。原因通常是你系统里装了多个 Qt 版本,环境变量 PATH 里指向了另一个版本的 bin 目录。解决办法是在 Qt Creator 的「项目 → 构建环境」里把 PATH 手动改一下,确保指向当前套件的 Qt 版本。
第二类是undefined reference to 'vtable for XXX',这种一般是头文件里声明了虚函数但没有实现,或者 .pro 文件里漏了对应的源文件。检查 .pro 里的 SOURCES 和 HEADERS 列表,确认所有 .cpp 和 .h 都列进去了。
第三类是qt.qpa.plugin: could not find the Qt platform plugin "windows",这个在运行阶段出现,说明 Qt 找不到平台插件。如果你是在 Qt Creator 里直接运行,一般不会遇到;但如果你把编译好的 exe 单独拷出来双击运行,就会报这个。解决办法是用windeployqt工具把依赖库拷到 exe 同目录下:
windeployqt your_game.exe --release这条命令会自动把 Qt5Core.dll、Qt5Gui.dll、Qt5Widgets.dll 以及 platforms 目录下的 qwindows.dll 拷过来。参数--release表示按 release 模式部署,如果你编译的是 debug 版本,改成--debug。执行完之后目录里会多出一堆 dll 和文件夹,这时候再双击 exe 就能跑了。
2.3 最小可玩版本的验证清单
构建成功之后,先别急着改代码,按下面这个清单验证一遍基础功能是否正常:
| 检查项 | 预期表现 | 异常时的排查方向 |
|---|---|---|
| 窗口能否正常显示 | 出现游戏主界面,有网格背景 | 检查 QWidget 子类的构造函数里是否调用了 show() |
| 方块能否自动下落 | 方块每隔固定时间下移一格 | 检查 QTimer 的 start() 是否被调用,interval 是否合理 |
| 键盘左右键能否移动 | 按左右键方块水平移动 | 检查 keyPressEvent 里是否处理了 Qt::Key_Left 和 Qt::Key_Right |
| 方块能否旋转 | 按上键方块旋转 | 检查旋转逻辑里的坐标变换矩阵 |
| 满行能否消除 | 填满一行后方块消失,上方方块下移 | 检查消行逻辑里的数组遍历方向 |
这个清单跑完,说明代码的基本逻辑是完整的。接下来才是拆解和改造。
3. 拆开看:QT C++ 俄罗斯方块源代码的四个核心模块
3.1 方块数据结构与旋转算法的实现细节
俄罗斯方块的核心数据结构通常是一个二维数组,每个格子用 0 表示空、1 表示有方块。七种方块(I、O、T、S、Z、J、L)各自用一个 4x4 或 3x3 的矩阵表示。旋转的本质是矩阵转置加翻转:顺时针旋转 90 度等于先转置再水平翻转,逆时针旋转等于先转置再垂直翻转。
// 顺时针旋转 90 度 void rotateMatrix(int matrix[4][4], int size) { // 先转置 for (int i = 0; i < size; i++) { for (int j = i + 1; j < size; j++) { std::swap(matrix[i][j], matrix[j][i]); } } // 再水平翻转 for (int i = 0; i < size; i++) { for (int j = 0; j < size / 2; j++) { std::swap(matrix[i][j], matrix[i][size - 1 - j]); } } }这段代码里size是矩阵的边长,I 型方块用 4x4,其余用 3x3。转置的循环从j = i + 1开始,避免重复交换。水平翻转只遍历一半的列,因为交换是对称的。实际项目中,旋转之后还要做一次碰撞检测,如果旋转后的位置和已有方块重叠或者超出边界,就要回退这次旋转。很多源代码在这里偷懒,不做回退检测,导致方块旋转后卡进墙里或者叠到其他方块上。
3.2 QPainter 绘图与 QTimer 定时器的配合方式
Qt 里画游戏界面一般用 QPainter 在 QWidget 的 paintEvent 里绘制。每个格子画一个矩形,填充颜色,再加边框。QTimer 负责驱动游戏循环,每隔一个时间间隔触发一次 update(),update() 会间接调用 paintEvent。
// 在构造函数里设置定时器 timer = new QTimer(this); connect(timer, &QTimer::timeout, this, &TetrisWidget::onTick); timer->start(500); // 初始下落间隔 500ms // 定时器回调 void TetrisWidget::onTick() { if (!moveDown()) { // 无法下移,说明触底了 lockPiece(); // 固定当前方块 clearLines(); // 检查并消除满行 spawnNewPiece(); // 生成新方块 } update(); // 触发重绘 }timer->start(500)里的 500 是毫秒数,控制方块下落速度。这个值越小,下落越快。实际游戏中会根据等级动态调整,比如每消 10 行升一级,间隔减少 50ms,最低不低于 100ms。connect用的是 Qt 5 的新式信号槽语法,编译期就能检查参数类型是否匹配。如果你拿到的代码用的是SIGNAL和SLOT宏,那是 Qt 4 的老写法,也能用,但建议改成新式语法。
3.3 键盘事件处理与长按重复触发
Qt 的 keyPressEvent 默认不会重复触发,按住左键不放,方块只会移动一格。要支持长按连续移动,有两种做法。一种是在 keyPressEvent 里判断event->isAutoRepeat(),如果是自动重复就忽略,然后在 keyReleaseEvent 里处理。另一种是用 QTimer 自己做按键重复,按下时启动一个短间隔定时器,松开时停止。
void TetrisWidget::keyPressEvent(QKeyEvent *event) { if (event->isAutoRepeat()) { event->ignore(); return; } switch (event->key()) { case Qt::Key_Left: moveLeft(); break; case Qt::Key_Right: moveRight(); break; case Qt::Key_Up: rotatePiece(); break; case Qt::Key_Down: moveDown(); break; case Qt::Key_Space: hardDrop(); // 直接落底 break; default: QWidget::keyPressEvent(event); } update(); }isAutoRepeat()返回 true 表示这是系统自动重复产生的事件,直接忽略掉,避免一次长按触发几十次移动。hardDrop()是空格键直接落底,落底后立即锁定方块并生成新的。这个操作在竞速玩法里很关键,能大幅减少操作时间。
3.4 消行逻辑与分数计算的边界条件
消行的逻辑是遍历每一行,如果该行所有格子都不为 0,就把这一行删掉,上面的行整体下移。实现的时候要注意遍历方向:从下往上遍历,遇到满行就删除,然后继续检查同一索引位置(因为上面的行已经移下来了)。
int TetrisWidget::clearLines() { int linesCleared = 0; for (int row = ROWS - 1; row >= 0; row--) { bool full = true; for (int col = 0; col < COLS; col++) { if (board[row][col] == 0) { full = false; break; } } if (full) { // 把 row 以上的所有行下移一行 for (int r = row; r > 0; r--) { for (int c = 0; c < COLS; c++) { board[r][c] = board[r - 1][c]; } } // 最上面一行清空 for (int c = 0; c < COLS; c++) { board[0][c] = 0; } linesCleared++; row++; // 因为下移了,需要重新检查当前行 } } // 计分:一次消 1 行 100 分,2 行 300 分,3 行 500 分,4 行 800 分 score += (linesCleared == 1) ? 100 : (linesCleared == 2) ? 300 : (linesCleared == 3) ? 500 : (linesCleared == 4) ? 800 : 0; return linesCleared; }row++这行是容易漏掉的细节。删除一行后,上面的行下移,当前 row 索引位置现在是原来 row-1 的内容,需要重新检查。如果不加这行,连续满行的情况会漏消。计分规则用的是经典俄罗斯方块的标准:一次消 4 行(Tetris)给 800 分,鼓励玩家攒着一次消。
4. 参数调优与功能扩展:让源代码变成你自己的版本
4.1 下落速度曲线与等级系统的参数设计
原始代码通常只用一个固定的定时器间隔,玩到后面会觉得很无聊。加入等级系统之后,下落速度随等级提升而加快。常见的做法是维护一个速度表:
| 等级 | 下落间隔(ms) | 升级所需消行数 |
|---|---|---|
| 1 | 500 | 10 |
| 2 | 450 | 20 |
| 3 | 400 | 30 |
| 4 | 350 | 40 |
| 5 | 300 | 50 |
| 6 | 250 | 60 |
| 7 | 200 | 70 |
| 8 | 150 | 80 |
| 9 | 100 | 90 |
| 10 | 80 | 100 |
实现的时候,每次消行后检查总消行数是否达到升级阈值,如果达到就更新timer->setInterval()。注意setInterval()会重置定时器,如果当前定时器正在运行,它会以新的间隔重新开始计时。这个行为在大多数情况下没问题,但如果你在定时器回调里调用setInterval(),可能会导致当前这次回调被中断。稳妥的做法是用一个成员变量记录当前间隔,在回调末尾判断是否需要更新。
4.2 用 Qt Designer 快速替换界面样式
源代码里的界面通常是纯代码绘制的,如果你想换成更现代的样式,可以用 Qt Designer 做一个 .ui 文件,然后把游戏区域嵌入进去。具体做法是:新建一个 QWidget 子类,在 Designer 里拖一个 QWidget 作为游戏画布,设置好大小和背景色,然后在代码里把这个 QWidget 提升为你的 TetrisWidget 类。右侧放几个 QLabel 显示分数、等级、下一个方块预览。
// 在 MainWindow 构造函数里绑定 ui->setupUi(this); tetrisWidget = new TetrisWidget(this); ui->gameLayout->addWidget(tetrisWidget); // 连接信号槽,更新分数显示 connect(tetrisWidget, &TetrisWidget::scoreChanged, this, [this](int score) { ui->scoreLabel->setText(QString("分数: %1").arg(score)); });ui->gameLayout是 Designer 里放的布局管理器,addWidget把游戏画布塞进去。scoreChanged是自定义信号,在 TetrisWidget 里每次分数变化时 emit 一次。用 lambda 表达式连接信号槽可以省去单独定义槽函数的麻烦,但要注意 lambda 里捕获的this指针生命周期,确保 MainWindow 比 TetrisWidget 活得久。
4.3 下一个方块预览与 Hold 功能的实现思路
下一个方块预览的实现方式是:在 TetrisWidget 里维护一个nextPiece变量,每次生成新方块时,把nextPiece赋给当前方块,然后随机生成一个新的nextPiece。预览区域用另一个 QWidget 子类来画,重写它的 paintEvent,根据nextPiece的类型画对应的形状。
Hold 功能是让玩家把当前方块存起来,等需要的时候再换出来。实现上需要两个变量:holdPiece和canHold。按下 Hold 键时,如果canHold为 true,就把当前方块和holdPiece交换,如果holdPiece为空就直接存进去并生成新方块。交换之后把canHold设为 false,直到当前方块锁定后才重新设为 true。这个限制防止玩家无限次交换。
void TetrisWidget::holdCurrentPiece() { if (!canHold) return; if (holdPiece == nullptr) { holdPiece = currentPiece; spawnNewPiece(); } else { std::swap(holdPiece, currentPiece); // 重置当前方块位置到顶部中央 currentX = COLS / 2 - 2; currentY = 0; } canHold = false; update(); }canHold在lockPiece()里重新设为 true。std::swap交换两个指针,注意交换后当前方块的位置要重置,否则会出现在奇怪的地方。
5. 避坑与排查:QT C++ 俄罗斯方块源代码改造中的五个翻车现场
5.1 方块旋转后穿墙或叠块
现象:按上键旋转方块,方块直接卡进左右墙壁里,或者叠到已有方块上面。原因:旋转逻辑只做了矩阵变换,没有做碰撞检测和回退。解决:旋转前先保存当前矩阵,旋转后调用isValidPosition()检查,如果返回 false 就把矩阵恢复回去。isValidPosition()要同时检查边界和已有方块占用。
5.2 消行后分数不更新或更新错误
现象:消了一行,分数没变,或者一次消四行只加了 100 分。原因:计分逻辑写在了错误的位置,或者在循环里重复计分。解决:把计分放在消行循环结束后,用linesCleared变量统一计算。不要在循环内部累加,否则一次消四行会被当成四次消一行。
5.3 定时器间隔改了但不生效
现象:调用了timer->setInterval(200),但方块下落速度没变。原因:setInterval()只对下一次start()生效,如果定时器已经在运行,需要先stop()再start()。解决:改成timer->stop(); timer->setInterval(200); timer->start();或者直接用timer->start(200),start()带参数的重载会同时设置间隔并启动。
5.4 键盘长按导致方块瞬移到底部
现象:按住下键不放,方块瞬间落到底部。原因:keyPressEvent里没有过滤isAutoRepeat(),系统自动重复的事件被当成多次按键处理。解决:在keyPressEvent开头加if (event->isAutoRepeat()) return;,或者在keyReleaseEvent里处理长按逻辑。
5.5 发布版本缺少 dll 导致无法运行
现象:在 Qt Creator 里能跑,把 exe 拷到别的电脑上双击没反应。原因:缺少 Qt 运行时库和平台插件。解决:用windeployqt工具自动部署,或者手动把 Qt5Core.dll、Qt5Gui.dll、Qt5Widgets.dll、platforms/qwindows.dll 拷到 exe 同目录。注意 release 和 debug 版本的 dll 不能混用,debug 版的 dll 带 d 后缀。
6. 从能跑到好用:给俄罗斯方块加一个「后悔药」和帧率无关的下落逻辑
原始代码里最影响手感的问题有两个:一是没有撤销机制,手滑放错位置只能重开;二是下落逻辑和帧率绑定,如果电脑卡顿,方块下落速度会变慢。这两个问题在改造版里都可以解决。
先说撤销机制。思路是每次锁定方块之前,把当前棋盘状态和分数存一份快照到栈里。按下撤销键时,从栈顶弹出快照恢复。快照的数据结构可以用一个结构体:
struct GameSnapshot { int board[ROWS][COLS]; int score; int level; int linesCleared; }; std::stack<GameSnapshot> undoStack; void TetrisWidget::saveSnapshot() { GameSnapshot snap; memcpy(snap.board, board, sizeof(board)); snap.score = score; snap.level = level; snap.linesCleared = linesCleared; undoStack.push(snap); // 限制栈深度,防止内存无限增长 if (undoStack.size() > 10) { // std::stack 不支持直接删底部元素,可以用 deque 替代 } } void TetrisWidget::undo() { if (undoStack.empty()) return; GameSnapshot snap = undoStack.top(); undoStack.pop(); memcpy(board, snap.board, sizeof(board)); score = snap.score; level = snap.level; linesCleared = snap.linesCleared; update(); }memcpy用来拷贝整个二维数组,比循环赋值快。栈深度限制在 10 步以内,避免内存占用过大。如果用std::stack,删除底部元素不方便,可以换成std::deque,用push_back和pop_front实现固定大小的队列。
再说帧率无关的下落逻辑。原始代码用 QTimer 固定间隔触发下落,如果系统卡顿,定时器回调被延迟,方块下落就会变慢。改成基于时间的下落:记录上一次下落的时间戳,每次 paintEvent 或者一个高频定时器里检查当前时间减去上次下落时间是否超过下落间隔,如果超过就执行下落并更新时间戳。
qint64 lastDropTime = QDateTime::currentMSecsSinceEpoch(); int dropInterval = 500; // 毫秒 void TetrisWidget::onFrameTick() { qint64 now = QDateTime::currentMSecsSinceEpoch(); if (now - lastDropTime >= dropInterval) { if (!moveDown()) { lockPiece(); clearLines(); spawnNewPiece(); } lastDropTime = now; update(); } }onFrameTick用一个 16ms 的定时器驱动,相当于每秒检查 60 次。dropInterval根据等级动态调整。这样即使某一帧被延迟,下一帧会检查时间差,如果超过一个间隔就补一次下落,保证整体速度不受帧率影响。这个思路在游戏开发里叫「固定时间步长」,是解决帧率波动的标准做法。
我自己改这份代码的时候,最大的教训是不要一上来就重构。先把原始版本跑通,确认每个模块都能正常工作,然后再一个功能一个功能地加。每加一个功能就提交一次代码,出问题了可以快速回退。俄罗斯方块看着简单,但状态管理一旦乱掉,调试起来非常痛苦。希望帮到你。
本文还有配套的精品资源,点击获取