简介:一款基于QT与C++实现的经典俄罗斯方块游戏源码,适合正在学习Qt GUI编程、C++面向对象设计或入门游戏开发的读者。项目完整演示了核心游戏机制,包括方块形状定义、随机生成、碰撞检测、行消除、计分与速度控制,并借助Qt的QGraphicsView/QGraphicsItem绘制游戏区域、QTimer控制下落节奏,信号与槽机制处理旋转与移动等按键交互,界面动画也能通过QGraphicsView实现平滑移动与旋转效果。代码采用模块化思路:每种方块类型对应独立类,主逻辑封装在Game/Tetris类中,头文件与实现文件分离,同时附带.pro工程文件与qmake构建配置,便于理解C++项目的组织与编译流程;随机方块序列生成、行消除判定等辅助功能也单独封装成函数,符合函数式与模块化设计思想,便于复用与局部调试。压缩包共12个文件,以4个cpp源文件、3个h头文件为主,辅以pro工程文件、user配置及png/jpg界面预览截图,整体仅58KB,结构精简紧凑。已有466人学习下载,适合希望通过小型游戏项目夯实Qt应用开发、事件驱动编程与C++工程组织能力的初学者,也值得有经验开发者参考其经典实现思路。
1. 俄罗斯方块入门即翻车点:旋转为何会“穿墙”
初学 QT C++ 俄罗斯方块的人,十有八九死在同一个坑里:方块旋转动画写好了,转着转着,方块的一半出了边界,甚至直接跳到了别的格子上。这不是渲染 Bug,而是坐标变换的账没算对。玩过这个游戏的人都知道,俄罗斯方块的核心从来不是“画得好看”,而是一套不断重复的空间判定:掉到哪、卡在哪、满行怎么消。QT C++ 俄罗斯方块源代码这套工程,正是把“旋转一旦穿墙就全盘崩”这个反直觉的机制拆得最清楚的学习材料,它同时涉及面向对象建模、事件驱动和 GUI 渲染,把这些机制从头到尾串起来,才能真正看懂一个游戏循环是怎么被组织出来的。
2. 方块建模:旋转 90 度为什么不能靠“转图片”
2.1 七种方块其实只是一组坐标集合
大多数初学者的第一反应,是给每种方块准备一张图片,旋转就换图片,碰撞就量图片边缘。这套思路做出来的程序会非常别扭,因为你很快发现,换图片得到的方块位置是“视觉上转了”,但游戏逻辑里它到底占了哪几个格子,依然说不清楚。源代码把这个问题从根本上解决了:所有方块根本没有图片,只有坐标点集。
每种方块是若干小方块的集合,每个小方块用(x, y)表示它在棋盘网格里的位置。传统实现里,棋盘约定为 10 列、20 行的二维网格,每种方块由 4 个小方块组成,这就是 tetromino 的本质。代码里的定义大概长这样:
// 用坐标点描述一个方块的“形状”,一个点代表一个格子 struct Tetromino { QVector<QPoint> cells; // 例如 I 型初始态:{(0,1),(1,1),(2,1),(3,1)} QColor color; };这里的QPoint不再是像素点,而是“网格坐标”。第一象限的(0,1)表示第 0 行第 1 列,注意顺序:通常 x 是列方向、y 是行方向,向下为正。这是整个游戏的数据地基,游戏世界里不存在像素,只有格子和坐标。旋转、移动、碰撞全部在这套坐标系里计算,像素只是最后渲染时的一次映射。
2.2 顺时针旋转的坐标公式:列变成行,行倒着变成列
现在到了关键点:一个(x, y)坐标绕着它所在形状包围盒旋转 90 度后,新坐标怎么算?如果按 4x4 网格来思考,顺时针旋转 90 度有一个非常直观的公式:
- 原来的列
x变成了新图形的行; - 原来的行
y要倒过来变成新图形的列。
写成代码就是:
// 在 4x4 网格内顺时针旋转 90 度 QPoint rotateClockwise(const QPoint& p) { // 新行 = 旧列;新列 = 网格宽度 - 1 - 旧行 return QPoint(p.y(), GRID_SIZE - 1 - p.x()); }为什么新列是3 - p.x()?列坐标越大说明越靠右,旋转 90 度后越靠右的行会翻转到越靠下的列。这个倒置是旋转公式里最容易记反的部分。如果用逆时针公式(GRID_SIZE - 1 - p.y(), p.x()),整个游戏会变成“按左键方块逆时针转”,界面方向全反。
旋转函数的输入输出都是网格坐标,这是它和 UI 解耦的核心:渲染层根本不需要知道方块旋转了没有,它只需要拿到旋转后的坐标集合,按照坐标重新摆放图形项。这也是“穿墙”问题为什么必然发生在数据层而不是视觉层的原因——如果旋转数据算错,界面再正确也是错的。
2.3 类设计:为什么每个方块单独建类
摘要里提到,工程中每种方块类型都有自己的类,成员变量和方法描述形状、颜色和行为。这种设计的直观好处是,游戏主逻辑可以和具体形状解耦:主循环说“把当前方块下移一格”,它不需要知道当前方块到底是 L 型还是 T 型,只需要调用统一的方法,得到统一的结果。
class Tetromino { public: const QVector<QPoint>& shape() const { return m_cells; } void rotate(); // 对 m_cells 逐点做旋转变换 void move(int dx, int dy); private: QVector<QPoint> m_cells; QColor m_color; };子类各自提供自己的初始形状,基类统一提供旋转、平移。这里隐藏着一个面向对象设计的重要决策:旋转逻辑如果放进子类里,七个子类就得多写七份几乎一样的代码;放进基类,所有方块共享同一套坐标变换规则,新增一种方块只需要提供初始坐标。从这一处设计就能看出,这个工程对“数据和逻辑分离”是有意识的,而不是随手写的 demo。
3. 游戏主循环:QTimer 如何驱动一帧一帧的“下落”
3.1 节拍器:interval 决定难度
GUI 程序没有 while 死循环,这是初学者从控制台程序转过来后最不习惯的地方。控制台游戏可以写for(;;) { update(); sleep(); },但 GUI 程序一旦这样写,窗口事件全被堵死,按钮点了没反应。QT 的解法是把循环拆散,靠一个节拍器定时触发:QTimer每隔固定的毫秒数发射一次 timeout 信号,游戏就在这个信号里推进一帧。
m_timer = new QTimer(this); m_timer->setInterval(500); // 初始每 500ms 下落一格 connect(m_timer, &QTimer::timeout, this, &Game::tick); m_timer->start();配合等级机制,每升一级setInterval减小一些,难度就上去了。这里的 interval 不是“画面刷新间隔”,而是“逻辑步进间隔”。画面刷新是垂直同步做的,跟逻辑帧无关。理解这个区别很重要,很多人把 QTimer 当作游戏引擎的 “fixed frame rate”,这其实不准确——它只是事件循环中的一个消息源,时间到了就往队列丢一个 timeout 事件。
下落的一帧只做一件事:尝试把当前方块往下移一格。如果移得动,本次 tick 结束;如果移不动,说明这个方块已经掉到既有方块或边界上,需要固化。
3.2 碰撞检测:先算候选位置,再决定是否提交
碰撞检测是这个游戏最频繁执行的操作,它的写法直接决定手感。高级做法是“先验证,后行动”,而不是“先移动,后回退”。工程里tryMove函数就是这个思路:
bool Game::tryMove(Tetromino& piece, int dx, int dy) { for (const QPoint& cell : piece.shape()) { int nx = cell.x() + dx; int ny = cell.y() + dy; // 左右越界,或掉出底部 if (nx < 0 || nx >= COL_COUNT || ny >= ROW_COUNT) { return false; } // 与已经固化的方块重叠 if (ny >= 0 && m_grid[ny][nx]) { return false; } } piece.move(dx, dy); // 校验通过才真正移动 return true; }注意m_grid[ny][nx]保存的是所有已经落地固化的小方块,m_grid 是bool或者颜色值的二维数组。这套判定的开销非常小:一个方块最多 4 个格子,每次移位只做 4 次数组访问。比起像素级相交检测,网格判定既快又精确。
这个函数还暴露了一个设计动机:为什么不直接修改方块位置再检测?因为在“尝试旋转”时,旋转后的坐标是临时的,如果旋转后碰壁,你把方块转回去了,视觉上会出现“抖一下”的效果,而且实现上要多写状态回滚代码。先试后改,天然就把回滚问题消灭了。
3.3 消行:为什么从下往上扫,扫完还要回头
当方块固化时,需要检查有没有哪一行被填满。消行的实现是多数人第二个翻车点。直观想法是扫每一行,满了就把上面所有行往下移。但移完之后,索引陷阱就来了:如果你是从上往下扫,移动行之后,当前没检查过的行位置已经变掉了,会漏检。
int Game::removeFullRows() { int removed = 0; for (int y = ROW_COUNT - 1; y >= 0; y--) { bool full = true; for (int x = 0; x < COL_COUNT; x++) { if (!m_grid[y][x]) { full = false; break; } } if (full) { for (int yy = y; yy > 0; yy--) { for (int x = 0; x < COL_COUNT; x++) { m_grid[yy][x] = m_grid[yy - 1][x]; } } for (int x = 0; x < COL_COUNT; x++) { m_grid[0][x] = false; } y++; // 当前的 y 位置被新行填充,必须重新检查 removed++; } } return removed; }从底部向上扫是第一个关键设计。消掉最底下一行后,所有行下移一格,此时如果继续y--,你正好跳过刚下移到这个位置的那一行,可能漏掉连续消行。y++是在抵消循环里的y--,让本轮重新检查同一行,这是多层消行的核心技巧。去掉这一句,双消、三消、四消时计分会明显不对劲,这也是很多改版俄罗斯方块计分有“玄学”的原因。
阶段内的状态迁移完整链条是:下落一帧 → 碰撞 → 固化 → 扫描消行 → 计分 → 生成下一块 → 判断是否顶到天花板。QTimer 每次 timeout 跑完全部流程,这就是单线程事件驱动的意义:所有状态都在同一个线程内同步修改,不存在并发竞争问题。
4. 渲染与交互:QGraphicsView 是怎么把矩阵“画”出来的
4.1 为什么用 Scene/Item 而不是整幅重绘
摘要里特别提到QGraphicsView和QGraphicsItem的使用。初学者常问:既然有 QPainter,为什么不用paintEvent把整个棋盘从头画一遍?答案在于更新粒度。paintEvent是“一次性画整张图”的思路,每次方块动一格,整个棋盘都要重新描绘;QGraphicsScene 则是场景图模型,每个方块是一个图形项,移动一个 item 只需要更新它所在的局部区域。
工程里的做法是:用QGraphicsRectItem表示已经固化的方块,棋盘背景用QGraphicsLineItem画网格线,当前活动的方块是一组单独的 item。每次 tick 推进,活动方块 item 的setPos被更新;消行时,删除对应行的 item 并把它上面所有 item 的setPos向下移动一格,只更新受影响的局部区域,而不是全屏闪烁。
一个关键配置是 Z 值排序:网格线在最底层,固化方块在中间层,活动方块在最顶层。这样即使视觉上有重叠,绘制顺序永远正确。
4.2 网格坐标到像素坐标的映射:乘格子大小就行
虽然游戏逻辑里全是网格坐标,但 QGraphicsItem 的位置必须用像素坐标设置。这个映射极简单:像素坐标等于网格坐标乘以格子边长。
constexpr int CELL_SIZE = 30; // 把一个图形项摆到指定网格位置 void placeItemAtGrid(QGraphicsItem* item, const QPoint& gridPos) { item->setPos(gridPos.x() * CELL_SIZE, gridPos.y() * CELL_SIZE); }为什么旋转方块时不直接给 item 设置旋转角度?这是很多人的误解。如果给QGraphicsItem设置 90 度旋转,视觉上方块确实转了,但 item 的 boundingRect 也跟着转了,碰撞判定用的网格坐标却还是旧的,于是出现“图形上转了、逻辑上没转”的撕裂。所以这套工程的旋转只发生在坐标集合上,渲染端只是重新setPos,二者严格分离。
棋盘尺寸随之推出来:10 列 × 30 像素就是 300 像素宽,20 行 × 30 像素就是 600 像素高。界面预览截图里看到的网格比例,就是像素映射的直接结果。改格子大小只需要改一个常量,整个界面等比例缩放。
4.3 信号槽链路:按一次键,代码里发生了什么
事件处理是游戏交互的命脉。按下旋转键后,从键盘到方块旋转,中间隔着一条信号槽链路,理解这条链路的执行模型比记 API 更重要:
connect(rotateButton, &QPushButton::clicked, this, &Game::rotatePiece); connect(this, &Game::moveRequested, this, &Game::tryMovePiece); // 键盘事件也可以直接连接到槽 connect(this, &Game::keyPressed, this, &Game::handleKey);信号槽默认是同步的:clicked()发射时,Qt 在同一线程直接调用rotatePiece(),执行完才往下走。所以槽函数里修改游戏状态,紧接着下一次 tick 读取这些状态时,数据是一致的。如果换成一个真正的多线程方案,信号的投递有延迟,就会出现“按了键但半秒后才转”的滞涩感。这个工程的所有组件都在 GUI 线程内,这是 QTimer 和信号槽能稳定配合的前提。
一个隐藏细节是:按键事件。键盘上按住方向键时,系统会连续产生 keyPress 事件,如果每次都触发一次移动,方块会连续移动。但如果不做任何处理,按下和释放之间会有系统级延迟,手感拖泥带水。源码里的处理方式是记录按键状态,tick 时统一读取,这个设计在下一章的踩坑里会详细展开。
5. 避坑排查:旋转跳位、消行残留和计时器玄学
5.1 旋转之后方块“跳位”或者整体跑偏
现象:方块旋转后,位置变了半格甚至一格,旋转几次后形状逐渐偏离预期位置。
原因:旋转公式里的GRID_SIZE - 1 - p.x()中的GRID_SIZE用的是整个棋盘的格数(比如 20),而不是方块所在包围盒的宽度(4)。坐标被按 20 去翻转,当然整个方块飞出去了。另一个常见原因是:旋转的基准点没固定。固定写法应该是“先平移再旋转最后平移回来”,绕方块自身包围盒转,而不是绕世界原点转。
解决:旋转前先算好旋转后的候选坐标集合,校验失败就不提交。可以写一个rotated()函数,它返回旋转后的新坐标数组,而不改动原对象:
QVector<QPoint> Game::rotatedCells(const Tetromino& piece) { QVector<QPoint> result; for (auto& cell : piece.shape()) { result << QPoint(cell.y(), 3 - cell.x()); // 固定 4x4 包围盒 } return result; }这样试转失败时,方块原地不动,视觉上只是在边界处“顶住”,不会跳位。
5.2 消行后界面上残留半截方块
现象:逻辑上能消行,计分也对,但界面上那一行还留着一些色块,或者下面的行没下来。
原因:逻辑层m_grid已经更新了,但图形项没有同步。固化方块是独立 item,消行时只改了二维数组,忘了删除对应的QGraphicsRectItem并重新摆放上面的 block。数据与渲染双轨制,一条更新另一条没跟上,这就是典型的“数据不同步”Bug。
解决:每次消行后,强制重建所有固化方块的图形项列表:清空场景里所有静态 item,再按m_grid逐格重建。虽然会多点开销,但换来了绝对一致性。这个“重建”思路看起来笨,却是初学者最容易上手的稳定方案;等熟练后再优化成增量更新。
5.3 按住方向键,移动先是特别慢,后来又突然连跳好几格
现象:键盘按下前半秒没反应,之后方块猛地往左移好几格。
原因:这是键盘自动重复和 QTimer 叠加造成的。按住键时,系统约每 300ms 重复发送一次 keyPressEvent,而游戏本身每 500ms 才 tick 一次。两个节拍各自触发移动逻辑,节奏不均匀;更糟的是,如果每次 keyPress 都重新启动移动定时器,还会和游戏主定时器互相干扰。
解决:不要用按键事件直接移动方块,而是用一组 bool 记录按键状态,在 tick 里统一处理:
void Game::keyPressEvent(QKeyEvent* event) { if (event->key() == Qt::Key_Left) m_leftPressed = true; if (event->key() == Qt::Key_Right) m_rightPressed = true; } void Game::tick() { if (m_leftPressed) tryMove(m_piece, -1, 0); if (m_rightPressed) tryMove(m_piece, 1, 0); // 然后处理向下掉落 }这样移动频率严格受 tick 控制,独立于键盘重复率,手感才稳定。
5.4 窗口失焦再回来,方块瞬间掉到底
现象:游戏中途切换窗口,回来后当前方块直接落到底部,或者整个界面“瞬移”了一截。
原因:失焦时 GUI 事件循环虽然还在跑,但 QTimer 的事件可能被其他系统事件挤在一起;恢复选中时,积压的 timeout 事件连续触发,一次 tick 看成好几帧,方块就一步到底了。这是 Qt 事件循环里很常见的现象:QTimer 保证的是“至少间隔”,不是“每次触发都间隔准确”。
解决:在start()之前先用stop()把定时器完全拎干净,更稳妥的方案是记录上一次 tick 的系统时间戳,如果时间差远大于正常 interval,就跳过本轮逻辑,直接作为新起点:
qint64 now = QDateTime::currentMSecsSinceEpoch(); if (now - m_lastTick > interval * 3) { m_lastTick = now; return; // 明显的时间跳变,丢弃这一帧 }这套思想在游戏引擎里叫“delta time 防帧间隔突变”,在手写逻辑的游戏里一样适用。
5.5 重新开始一局,旧方块还留在棋盘上
现象:点“重新开始”,得分归零了,但界面上残留着上一局的方块。
原因:只重置了当前活动方块和分数,忘了清空m_grid和已有的图形项。m_grid是全局状态,不清空的话,新方块一出生就会发现“底部已经有东西”,直接 Game Over。
解决:在初始化函数里写一个循环,把m_grid全部置为 false,同时把场景里的所有 item 清掉。你要是问我血泪经验,我一般会这么做:把“重置棋盘”独立成单独方法,在构造函数和新游戏入口都调用它,保证所有入口状态一致。
6. 设计取舍:把游戏逻辑从界面里剥出来,值不值
这套工程最值得揣摩的设计决策,是“矩阵优先”还是“图形项优先”。两种实现路线我在调试中都用过,体会很深。
图形项优先的做法是:方块本身就是一组 QGraphicsRectItem,旋转时直接改 item 坐标,碰撞时读取 item 位置换算成网格索引。它很直白,新手上手快,但有一个致命伤:逻辑和视觉绑死在一起。消行时,你得从视觉上推导哪一行被填满,再去移动一堆 item;棋盘尺寸修改时,所有坐标换算全部重来。代码写到最后,到处都是“取 item 坐标再除以格子大小”的脏代码,你没法在命令行环境里测试任何游戏逻辑。
矩阵优先的做法正好相反:所有逻辑都发生在m_grid和方块坐标集合上,UI 只是把矩阵状态翻译成图形项的位置。这套工程选择的就是这条路。好处是三件事变得非常干净:第一,消行逻辑可以用纯 C++ 数组实现,根本不需要 QGraphicsItem 参与;第二,碰撞检测在逻辑层完成,GUI 层只负责展示结果;第三,想写单元测试,可以直接创建 Game 对象而不带 GUI,喂一串输入断言网格最终状态,这让“旋转跳位”“消行残留”这类问题能在几毫秒内被测试暴露,而不是等肉眼盯着界面发呆。
代价也很实际:需要多写一层网格坐标和像素坐标的映射代码,以及一套“从 grid 重建图形项”的同步逻辑。对于只有 4 种形状、10 列棋盘的俄罗斯方块来说,这层映射大约也就几十行代码,成本低到可以忽略。可学的设计原则藏在背后:当状态变更只走一个方向——逻辑层修改状态,渲染层被动显示——程序的确定性就会大大提升。
从那以后我每次写游戏 demo,都强制先跑一遍纯逻辑层的输入输出测试,再把界面接上去。那些看起来“找不到任何原因”的界面错位,最后定位到的几乎都是数据没同步,渲染永远是无辜的。这套 QT C++ 俄罗斯方块工程值得自己动手改一版,从换一种消行计分方式开始,就能体会到逻辑与渲染解耦带来的踏实感。希望帮到你。
本文还有配套的精品资源,点击获取