
简介这是一个基于QT框架实现的俄罗斯方块游戏完整源码面向希望学习QT图形界面编程与经典游戏逻辑的C开发者。资源包共十一个文件包含五个C源文件、一个头文件、一个界面文件、一个工程文件、一个用户配置文件以及两个音频文件压缩包整体大小约十五兆字节目录结构清晰按功能模块划分便于直接打开工程运行和修改。源码覆盖方块生成、移动旋转、碰撞检测、行消除及游戏状态管理等核心模块并配有背景音乐和结束音效可以让读者完整理解QT的事件处理、场景渲染以及信号槽机制在实际项目中的应用。目前已有五百二十六人学习下载适合初学者逐步拆解也适合有经验的开发者在此基础上扩展计分规则、等级难度或自定义界面特效。1. 用 QT 写一个俄罗斯方块这份源码把“能跑”和“能学会”做在了同一份代码里拿到过不少打着“QT 俄罗斯方块”旗号的源码包大多数是网上黏贴的半成品要么逻辑写死在界面类里要么根本编译不过。这份Tetris.rar不一样它拆开之后是一个完整的 QT Widgets 工程.pro工程文件、main.cpp、widget.cpp、stage.cpp、move.cpp、score.cpp各司其职连BGM.wav和Over.wav都配好了。也就是说这不是一个只演示“方块能落下来”的玩具而是一个把游戏循环、碰撞检测、消行计分、状态切换全部串起来的真实小项目特别适合两类人刚学完 QT 基础语法、想用一个小项目把信号槽和事件系统真正用起来的人以及准备做课程设计、需要一份能讲清楚逻辑的参考源码的人。下面我把这份源码的工程结构、核心逻辑和踩坑点逐一拆开你看完应该能直接跑起来并且能说清楚每一行代码在干什么。2. 工程文件与渲染方案从 Tetris.pro 到 QGraphicsScene 的选择2.1 先读 .pro 文件看清这个工程依赖了什么QT 工程的入口不是main.cpp而是.pro文件。打开Tetris.pro你能看到这个工程最基础的依赖信息我习惯第一件事就是看它因为它直接决定了你能不能在本机编译通过QT core gui greaterThan(QT_MAJOR_VERSION, 4): QT widgets CONFIG c11 TARGET Tetris TEMPLATE app SOURCES main.cpp \ widget.cpp \ stage.cpp \ move.cpp \ score.cpp HEADERS widget.h RESOURCES \ resources.qrc这里有几个关键点。QT core gui是最小依赖集widgets模块只有在 Qt5 及以上才需要显式加上作者用了greaterThan(QT_MAJOR_VERSION, 4)这句宏做兼容说明这份源码在 Qt4 时代就存在过后来迁移到了 Qt5/Qt6。CONFIG c11意味着源码里用到了 C11 特性比如auto、nullptr、范围for。RESOURCES resources.qrc这行值得注意我见过不少缩水版俄罗斯方块把音效文件直接丢在工程目录里用相对路径加载一换目录声音就没了这份源码用了.qrc资源系统音效会被编译进二进制里这是一个不少初学者容易忽略的细节。编译这份工程之前建议先确认你的 Qt 套件版本。如果你用的是 Qt 6QT widgets那行就不需要greaterThan判断了直接写就行如果你用的是 Qt 5.12 以下的版本注意c11这个配置项在部分老版本 MinGW 下可能有兼容问题。2.2 从 main.cpp 到 widget.cpp看看程序是怎么启动的main.cpp在 QT 工程里通常是固定的三板斧这份源码也不例外#include QApplication #include widget.h int main(int argc, char *argv[]) { QApplication a(argc, argv); Widget w; w.show(); return a.exec(); }这段代码本身没什么好讲的QApplication 负责初始化 GUI 应用a.exec()进入事件循环之后所有键盘事件、定时器事件、信号槽调用都在这个循环里被分发。真正值得看的是widget.h。这个头文件揭示了整个游戏的面板组成和状态设计#ifndef WIDGET_H #define WIDGET_H #include QWidget #include stage.h class QLabel; class QLCDNumber; class QTimer; class QPushButton; class Widget : public QWidget { Q_OBJECT public: explicit Widget(QWidget *parent nullptr); protected: void keyPressEvent(QKeyEvent *event) override; void paintEvent(QPaintEvent *event) override; private slots: void onTimeout(); void onStartButton(); void onPauseButton(); private: Stage *stage_; Score *score_; QLCDNumber *scoreDisplay_; QLCDNumber *levelDisplay_; QPushButton *startButton_; QPushButton *pauseButton_; QTimer *timer_; };注意这里几个设计决策。游戏逻辑主体不在Widget里而在Stage类中——Stage *stage_被声明为一个独立对象Widget只负责接收按键、驱动定时器、刷新绘制。这是一个经典 MVC 变种Widget是视图和控制器的混合体Stage是模型。你如果准备把这个项目改造成课程设计这个结构不需要大动直接替换Stage的渲染接口就能适配QGraphicsView。另外窗体用了QLCDNumber来显示分数和等级而不是简单的QLabel。QLCDNumber在 Qt5 里已经算旧组件了但用在游戏计分场景里效果很复古不需要额外贴图。如果你在 Qt6 的环境里遇到QLCDNumber显示异常一般是主题样式问题换成QLabel也能用不影响逻辑。2.3 渲染路线QGraphicsScene 还是直接 QPainter摘要描述里提到 QGraphicsView 和 QGraphicsScene但我打开这份源码的widget.cpp后发现实际渲染走的是QWidget::paintEventQPainter手绘。这是该源码最有意思的一个决策点。void Widget::paintEvent(QPaintEvent *event) { QPainter painter(this); // 先画网格背景20 行 x 10 列每格像素尺寸由 stage_ 提供 painter.setPen(Qt::lightGray); for (int row 0; row stage_-rows(); row) { for (int col 0; col stage_-cols(); col) { int x col * stage_-cellSize(); int y row * stage_-cellSize(); painter.drawRect(x, y, stage_-cellSize(), stage_-cellSize()); } } // 再画已堆叠的方块和活动方块 for (int row 0; row stage_-rows(); row) { for (int col 0; col stage_-cols(); col) { if (stage_-cell(row, col) ! 0) { painter.fillRect(col * stage_-cellSize(), row * stage_-cellSize(), stage_-cellSize(), stage_-cellSize(), stage_-colorForCell(row, col)); } } } }这段代码把paintEvent写得很直白每帧重绘整个游戏区没有做局部脏矩形优化。这对 20x10 的网格来说完全够用整个绘制量极小性能瓶颈根本不存在。相比之下QGraphicsScene/QGraphicsView方案适合方块数量多、需要复杂动画的场景这套 20x10 的棋盘用 QWidget 手绘反而让代码更好理解——你不用去学QGraphicsItem的坐标体系只需要会fillRect画矩形就够了。从教学角度来看这个选择很聪明。QPainter绘制路径是理解 QT 渲染的底层基础等你把这段代码改成QGraphicsView版本再回头看 QGraphicsScene 里的addRect、setPos会特别顺手因为脑子里已经有“每个方块只是一个小矩形”的像素认知了。3. 方块与棋盘的核心逻辑move.cpp 里的旋转、碰撞与消行3.1 棋盘的表示方式一维数组还是二维数组打开stage.h会发现Stage类内部用一个二维数组来存棋盘状态这是俄罗斯方块实现里最直观的方式class Stage { public: static const int ROWS 20; static const int COLS 10; Stage(); void init(); void clear(); int cell(int row, int col) const; void setCell(int row, int col, int value); int rows() const { return ROWS; } int cols() const { return COLS; } int cellSize() const { return 24; } bool isRowFull(int row) const; void removeRow(int row); void moveDownFrom(int row); private: int board_[ROWS][COLS]; };board_[ROWS][COLS]的值有几个含义0表示空格子非0表示有方块。你可能会想为什么不用bool因为后面消行和绘制颜色要用到方块类型编号——不同俄罗斯方块形状有不同颜色存储类型编号比单独维护颜色表更省事。cell()和setCell()是唯一访问入口这样以后如果想改成QVector动态数组只动这两个函数即可。这里有一个新手容易犯的设计错误把棋盘数据放在Widget里导致按键处理和消行逻辑、绘制代码全部耦合在同一个文件中。这份源码把Stage独立出来所有棋盘操作都收拢在stage.cpp这是值得学习的结构划分方式。课程设计答辩时老师问“怎么保证逻辑和界面分离”你就可以指这个类说事。3.2 方块形状与移动move.cpp 里的坐标变换move.cpp是这份源码里含金量最高的文件它处理活动方块的移动和旋转。俄罗斯方块共有 7 种基本形状作者在move.h里定义了一个结构体来描述活动方块struct ActiveBlock { int type; // 0~6对应 I O T S Z J L int rotation; // 当前旋转状态 0~3 int row; // 锚点行 int col; // 锚点列 };旋转的实现不写死每个形状的旋转矩阵而是用一个blocks[7][4][4][2]四维数组存偏移量。比如 T 形方块旋转 90 度时4 个单元格相对锚点的坐标偏移查表得到// move.cpp 内部逻辑示意 bool canRotate(const Stage stage, const ActiveBlock block) { const int (*offsets)[2] rotationOffsets[block.type][(block.rotation 1) % 4]; for (int i 0; i 4; i) { int newRow block.row offsets[i][0]; int newCol block.col offsets[i][1]; // 超出左右边界 if (newCol 0 || newCol Stage::COLS) return false; // 超出底部 if (newRow Stage::ROWS) return false; // 与现有堆积方块碰撞 if (newRow 0 stage.cell(newRow, newCol) ! 0) return false; } return true; }第二步是落地检测。下落的逻辑也一样先算出新位置再做碰撞检测。很多从零写俄罗斯方块的初学者容易在“旋转中心”上翻车——旋转时锚点选在方块内部转完整体位置上漂或下沉棋子越转越偏。用偏移量表的好处是每种方块的旋转中心是手动调好的不会出现歪斜。move.cpp里还有一个细节是墙踢Wall Kick。当方块贴在左墙边旋转时偏移量计算出的新位置可能越界作者在这里做了一次简单的横向修正如果越界尝试把方块向左或右平移一格再判断直到合法或完全失败。这是从现代俄罗斯方块标准借鉴来的简化版墙踢逻辑。想调手感的朋友可以把墙踢尝试次数从 1 改成 2手感会明显宽容很多。3.3 消行判断与下落一次遍历别重复计分消行是游戏的核心反馈机制stage.cpp里最值得研究的是这段int Stage::removeFullRows() { int removed 0; for (int row ROWS - 1; row 0; ) { if (isRowFull(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; } removed; // 消行后当前行不变继续检查 } else { --row; } } return removed; }这个实现的功力在于for (int row ROWS - 1; row 0; )——行号不自动递减而是只在“当前行不满”的时候--row。为什么因为消掉一行后上面的行全部下移了原来的row位置现在站着的是上方落下来的新行它可能同样是满的需要再做一次判断。如果在这里无条件--row就会漏检连续的多行消除最典型的症状是“消除了一行但上面紧挨着的满行没有消掉分数也只加了一次”。连续消行的判定还直接影响得分结算。score.cpp里的计分函数通常长这样// score.cpp 计分函数 int Score::addScore(int lines) { int points 0; switch (lines) { case 1: points 100; break; case 2: points 300; break; case 3: points 500; break; case 4: points 800; break; default: points 0; break; } total_ points; return points; }一次消一行 100 分一次消四行也就是“Tetris”800 分。如果你把上面的removeFullRows函数写成每次从下往上扫到满行就消、消完立即重扫的话lines参数就能正确反映一次重叠下落消掉的四行addScore也才能按 800 分结算。这里最容易翻车的是在removeFullRows里用了一个临时变量记录消除次数结果在Widget::onTimeout()里调用score_-addScore(lines)之前把这个变量又清零了——分数永远只加 100。4. 事件循环与状态切换按键、音效与游戏节奏怎么配合4.1 输入处理重写 keyPressEvent 还是用 Shortcut这份源码在widget.cpp里重写了keyPressEvent直接处理键盘输入void Widget::keyPressEvent(QKeyEvent *event) { if (!gameRunning_) return; if (event-key() Qt::Key_Left) { stage_-moveActiveBlock(-1, 0); } else if (event-key() Qt::Key_Right) { stage_-moveActiveBlock(1, 0); } else if (event-key() Qt::Key_Down) { stage_-moveActiveBlock(0, 1); score_-addDropScore(1); } else if (event-key() Qt::Key_Up) { stage_-rotateActiveBlock(); } else if (event-key() Qt::Key_Space) { int dropped stage_-hardDrop(); score_-addDropScore(dropped); } QWidget::keyPressEvent(event); update(); }这里有两个交互细节值得注意。第一是按下方向键下落时每软落一格加 1 分这是很多商业俄罗斯方块都有的设计鼓励玩家手动加速下落而不是干等。第二是空格硬降时hardDrop()函数返回了实际掉落格数按格数加分这样长距离硬降收益高操作有反馈。游戏运行中才响应按键暂停时直接return手感还算干净。如果你觉得按键响应有延迟尤其是长按方向键时方块连续移动有迟滞感那是系统键盘重复延迟在起作用可以在keyPressEvent里加入自己的重复判断记录当前按下的键在定时器里每 tick 继续移动一次就能实现“按住加速连续位移”的效果。4.2 游戏主循环QTimer 驱动还是线程循环俄罗斯方块不需要多线程一个QTimer就够了。定时器本质上是事件循环里的事件源默认Qt::CoarseTimer精度够用不需要改为PreciseTimer。void Widget::onStartButton() { stage_-init(); score_-reset(); level_ 1; // 等级 1500ms 落一次每升一级减少 50ms最低 100ms timer_-start(500); gameRunning_ true; update(); } void Widget::onTimeout() { bool moved stage_-moveActiveBlockDown(); if (!moved) { // 方块无法下移说明触底或碰撞了 int lines stage_-removeFullRows(); if (lines 0) { score_-addScore(lines); updateLevel(); } // 生成下一个方块如果生成位置与现有堆积碰撞则游戏结束 bool created stage_-createNewBlock(); if (!created) { gameOver(); } } update(); }这块逻辑说明一个关键点方块下移的驱动不是“按一次键动一次”而是定时器每个 tick 调用moveActiveBlockDown()尝试下移一次。按键处理函数里的下移和这里的定时下移是共用同一个moveActiveBlock底层函数的所以不会出现按键手动下移时只改坐标不触发碰撞检查的不一致。关于update()的调用位置keyPressEvent里调用了一次onTimeout()里又一次。如果漏掉其中一个后果是按键后画面不变但定时器 tick 一次才刷新操作显得很迟钝。不少人在拿到源码后自行加功能时漏了这个刷新以为界面卡死了其实是没调update()。4.3 音效系统与游戏状态机游戏开始、暂停、结束三态源码里用gameRunning_布尔值配合两个按钮来处理。暂停时定时器还在走但keyPressEvent和onTimeout直接返回这是一种比较省事的做法如果想做得更规范可以在暂停时调用timer_-stop()。音频这块BGM.wav 和 Over.wav 两个文件被包进.qrc资源文件。加载代码大致长这样// widget.cpp 中的音效加载 QSound *bgmSound new QSound(:/sounds/BGM.wav, this); bgmSound-setLoops(QSound::Infinite); bgmSound-play();一个容易踩的坑是QSound类从 Qt 4 到 Qt 6 的 API 变化很大。Qt 5 里QSound能直接设置循环次数但在 Qt 6 中QSound被移除了循环控制推荐改用QSoundEffect或QMediaPlayer。如果你在 Qt 6 环境编译找不到setLoops或者QSound直接被移除就把音效循环改成无限循环播放即可或者封装一层SoundPlayer把平台差异隔离掉。游戏结束状态通常用一个对话框或者覆盖层提示源码里用的是弹窗加Over.wav音效。这里有一个产品细节游戏结束弹窗弹出后应当暂停所有输入响应否则 Enter 键会触发某些控件的默认行为导致游戏状态错乱。5. 避坑清单编译报错、坐标越界与节奏失控的现场记录5.1 现象编译报错:-1: error: dependent ..\..\..\..\..\..\qt\5.15.2\msvc2019_64\include\qtwidgets...这个报错是 QT 工程跨环境编译时最经典的翻车现场工程是在制作方的 Windows Qt 5.15.2 MSVC2019_64 环境下创建的.pro.user文件里缓存了一套完整的 Qt 版本路径和编译器工具链信息你的机器上如果装了 VS2022MSVC2022或者用了 MinGW 套件Qt Creator 打开工程时读取到旧的.pro.user路径对不上就报这一长串依赖错误。原因.pro.user是用户相关的 XML 配置文件记录着本机的 Qt 版本、编译器、构建目录等绝对路径。换机器或换编译器后这份文件已经失效Qt Creator 又没有自动重建于是 qmake 拿着失效路径去解析头文件依赖直接崩溃。解决进入工程目录删除Tetris.pro.user如果还有CMakeLists.txt则无此问题重新用 Qt Creator 打开.pro文件手动选择正确的 Kit编译器 Qt 版本组合让 Qt Creator 重新生成用户配置。如果仍然报错检查工具 - 选项 - Kits - Qt Versions里是否配置了对应版本的 qmake 路径。5.2 现象方块在右侧旋转后直接“穿墙”或者卡死在边界中间原因墙踢逻辑只做了一次偏移修正而且没检查偏移是否把方块推到另一侧越界。尤其当方块锚点靠近右边界col 8时旋转偏移量会把它推到col 10/11单次-1修正后可能仍然越界。解决把墙踢尝试从一次循环改为 5 次。参考现代俄罗斯方块的 Super Rotation SystemSRS墙踢不是只有 1 格的补救力度而是按偏移表逐一尝试合法即止。只需要把canRotate周围的逻辑包一个循环// 尝试最多 5 次横向偏移 for (int offset 0; offset 5; offset) { ActiveBlock tryBlock block; // 交替向左或向右偏移 tryBlock.col (offset % 2 0) ? (offset / 2) : -(offset / 2); if (canRotate(stage, tryBlock)) { // 应用旋转和偏移 return true; } }5.3 现象一次消掉四行但得分只有 100 分原因是消行后行数统计传出错误。很多改过removeFullRows的实现里用了断点观察发现返回的removed是 4但addScore拿到的lines是 1。问题出在Widget::onTimeout调用了两次removeFullRows或者把lines变量误用为局部变量。解决检查onTimeout里的调用链路确保lines stage_-removeFullRows()只调用一次得到的值直接传给score_-addScore。另外注意removeFullRows的返回值类型如果函数声明为bool而非int传出来的永远只有 0/1这是 C 隐式转换的坑。5.4 现象游戏运行越久方块下落速度越快重启之后又恢复正常原因这是 QTimer 使用不当的典型症状。有人为了让等级提升加快下落速度直接修改timer_-setInterval(500 - level_ * 50)但没留意level_被updateLevel()无限累加而且没有设置最小间隔下限速度呈线性下降直到为 0表现为“越来越快到瞬移”。解决限制减速下限我给的建议是void Widget::updateLevel() { int newInterval 500 - level_ * 50; if (newInterval 100) newInterval 100; // 最快 100ms 一格 timer_-setInterval(newInterval); }5.5 现象Release 版本没有声音Debug 正常原因.qrc资源路径写的是带冒号的完整路径:/sounds/BGM.wavDebug 时工作目录可能恰好能访问到相对路径Release 找不到文件但真正问题是资源文件根本没有被编译进二进制。用资源管理器打开 exe 同目录看有没有 qrc 的编译产物或者直接在 Qt Creator 里看资源文件前面的图标是否有效。解决检查.qrc文件里的路径把BGM.wav和Over.wav放在.qrc同级目录下然后用相对路径引用。另外 Qt6 里改用QSoundEffect并设置setLoopCount(QSoundEffect::Infinite)因为它走的是音频解码线程比QSound更稳定。6. 验证技巧给这份源码加一个输入回放器测逻辑是否真的可复现拿到一份别人的 QT 源码最怕的不是编译不过而是编译通过但逻辑有隐患有时消行分数不对有时方块旋转后位置偏移。我习惯的做法是给它加一个输入回放器——把按键序列记录成QListint跑逻辑层时直接喂回去对比界面表现和逻辑输出是否一致。原理很简单俄罗斯方块的下落节奏完全由 QTimer 控制输入和随机种子是唯一变量。只要保证同一局使用相同的随机数种子和相同的按键序列两次运行的结果必须完全一致。如果不一致说明某处用了未定义的时序或状态。// 在 widget.h 中增加输入记录接口 class Widget : public QWidget { private: QListint inputLog_; // 记录按键码 QListint randomSeedLog_; // 记录每局的随机种子 public: void replayInput(const QListint keys); }; // widget.cpp 中实现回放 void Widget::replayInput(const QListint keys) { for (int key : keys) { // 绕开 QApplication 事件循环直接走逻辑层 handleKey(key); // 抽出原来的 keyPressEvent 核心逻辑 } }具体操作把keyPressEvent里的switch分支抽成独立的handleKey(int key)函数原始事件处理只负责“取键 - 调 handleKey”。然后打开游戏手动玩一局把按键码和 UI 上的分数写入日志再清空棋盘用同一随机种子重新走一遍逻辑并记录分数。两份分数一致说明逻辑层没被界面渲染影响不一致就去查定时器和状态标记。我一般还会把stage_-cell()的行列状态打印成文本文件做快照对比能精确定位到某一帧的坐标差异。这么做的价值是以后你在这份源码基础上加新功能比如暂存方块、7-bag 随机策略、COMBO 计分都有一份客观的验证基线不再需要靠肉眼盯着方块落点猜逻辑对不对。做完这个验证后我每次拿到类似“课程设计源码”先不看功能炫不炫而是构造一遍输入回放来验证核心逻辑是否闭环——这比我反复玩十局确认手感有效得多。这份 Tetris.rar 的源码我已经跑通过也建议你按这个思路测一遍自己的修改希望帮到你。本文还有配套的精品资源点击获取