ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Qt Widgets俄罗斯方块源码:可编译可扩展的工程实践

Qt Widgets俄罗斯方块源码:可编译可扩展的工程实践 简介这是一份面向QT初学者与游戏开发爱好者的俄罗斯方块完整源码基于QT框架与C实现适合作为GUI编程与游戏逻辑结合的实战案例。资源包共11个文件约15.18MB包含5个cpp源文件、1个头文件、1个ui界面文件、1个pro工程文件以及2个wav音效资源覆盖游戏主逻辑、界面布局与背景音乐等模块。源码围绕QGraphicsView与QGraphicsScene构建显示区域实现了方块生成、旋转、下落、碰撞检测与行消除等核心机制并通过信号与槽响应用户键盘输入管理开始、暂停、结束等游戏状态。目前已有526人学习下载读者可借此理解QT事件处理、坐标变换与资源加载方式对照源码梳理游戏循环与得分逻辑适合作为课程设计或自学练手项目。1. Tetris_QT俄罗斯方块_源码一份能编译、能改、能发布的最小工程很多人搜「Tetris_QT俄罗斯方块_源码」其实不是想找一份玩具代码而是想找一个能直接编译、能看懂结构、能顺手改成自己项目的 Qt 工程。我见过太多所谓源码打开一看界面和逻辑全塞在main.cpp里几百行paintEvent硬画方块改一个下落速度要翻半天。这种代码拿来交作业可以拿来学 Qt 就是浪费时间。这份方案要解决的就是这件事用 Qt Widgets 做一个结构清晰、可编译、可扩展的俄罗斯方块。核心思路是把「棋盘数据」「方块形状」「渲染」「输入」「计时」拆成独立模块让每一块都能单独替换。适合两类人一是刚学完 Qt 基础、想找一个完整项目练手的开发者二是需要快速搭一个 2D 网格游戏骨架、后面要接网络对战或 AI 落点的人。Qt 的跨平台特性在这里很关键。同一份代码在 Windows 上用 MinGW 编译在 Linux 上用 GCC 编译在树莓派上交叉编译后也能跑不需要改一行逻辑。俄罗斯方块本身规则简单、状态机清晰是验证 Qt 事件循环、绘图系统和定时器机制的最佳载体。下面从工程结构开始一步步把可运行的源码拆开讲。2. 工程结构与 Qt 模块选型为什么不用 QML 而用 QWidget2.1 用 QWidget 还是 QML俄罗斯方块的渲染路径对比Qt 提供两套 UI 方案Qt Widgets 和 Qt Quick/QML。俄罗斯方块这种固定网格、低刷新率、强逻辑耦合的场景QWidget 更合适。原因有三点。第一绘制控制更直接。QWidget 的paintEvent配合QPainter可以精确控制每个格子的绘制顺序和坐标不需要理解 QML 的场景图渲染管线。第二事件处理更简单。键盘输入用keyPressEvent就能拿到不需要在 QML 里写Keys.onPressed再转发到 C。第三调试成本低。QWidget 的坐标系是像素级的出问题直接看坐标值QML 的锚点布局在复杂嵌套时容易出玄学问题。常见做法是主窗口继承QMainWindow中央区域放一个自定义的QWidget子类作为游戏画布右侧或下方用QLabel显示分数、等级、下一个方块。整个工程只需要QtCore、QtGui、QtWidgets三个模块不需要QtNetwork、QtMultimedia这些额外依赖编译出来的可执行文件体积小发布时用windeployqt或linuxdeployqt打包即可。注意如果你用的是 Qt 6QWidget相关类在QtWidgets模块里CMake 中要写find_package(Qt6 COMPONENTS Widgets REQUIRED)不要写成Qt5否则会出现unknown module(s) in qt: widgets这类报错。2.2 工程目录与 CMake 配置从零搭出可编译骨架一个能长期维护的 Qt 工程目录结构要清晰。我一般会这样组织TetrisQt/ ├── CMakeLists.txt ├── src/ │ ├── main.cpp │ ├── mainwindow.h │ ├── mainwindow.cpp │ ├── gameboard.h │ ├── gameboard.cpp │ ├── tetromino.h │ ├── tetromino.cpp │ └── config.h └── resources/ └── styles.qssconfig.h放常量棋盘列数、行数、格子像素大小、下落间隔。tetromino.h定义七种方块的形状矩阵和旋转逻辑。gameboard负责棋盘状态、碰撞检测、消行。mainwindow负责组装界面、处理键盘、驱动定时器。对应的CMakeLists.txt最小配置如下cmake_minimum_required(VERSION 3.16) project(TetrisQt LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_AUTOMOC ON) set(CMAKE_AUTORCC ON) find_package(Qt6 COMPONENTS Widgets REQUIRED) add_executable(TetrisQt src/main.cpp src/mainwindow.cpp src/gameboard.cpp src/tetromino.cpp ) target_link_libraries(TetrisQt PRIVATE Qt6::Widgets)CMAKE_AUTOMOC ON必须开因为QMainWindow和自定义QWidget子类里如果有Q_OBJECT宏需要 moc 预处理。CMAKE_AUTORCC ON用于把.qrc资源文件编译进去如果你要加图标或样式表这个也要开。编译命令cmake -B build -DCMAKE_BUILD_TYPERelease cmake --build build --config Release在 Windows 上如果用的是 MinGW生成的是build/TetrisQt.exe在 Linux 上是build/TetrisQt。如果编译时报cannot find -lpublic之类的链接错误检查target_link_libraries里有没有漏掉Qt6::Widgets或者 Qt 安装路径没有加到CMAKE_PREFIX_PATH。2.3 棋盘数据模型用二维数组还是位运算棋盘状态用int board[ROWS][COLS]就够了每个格子存 0 表示空1 到 7 表示对应方块颜色索引。不需要用位运算压缩因为俄罗斯方块棋盘最大也就 20 行 10 列200 个整数占不到 1KB 内存可读性远比省内存重要。// config.h #pragma once constexpr int ROWS 20; constexpr int COLS 10; constexpr int CELL_SIZE 30; constexpr int INIT_INTERVAL 500; // 毫秒 // gameboard.h class GameBoard : public QWidget { Q_OBJECT public: explicit GameBoard(QWidget *parent nullptr); void startGame(); void pauseGame(); int score() const { return m_score; } int level() const { return m_level; } protected: void paintEvent(QPaintEvent *event) override; void keyPressEvent(QKeyEvent *event) override; private slots: void onTick(); private: bool checkCollision(const Tetromino piece, int dx, int dy) const; void lockPiece(); void clearLines(); void spawnPiece(); int m_board[ROWS][COLS]{}; Tetromino m_current; Tetromino m_next; QTimer *m_timer; int m_score 0; int m_level 1; int m_interval INIT_INTERVAL; bool m_paused false; bool m_gameOver false; };m_board初始化为全零。checkCollision遍历当前方块占用的四个格子判断是否越界或与已锁定格子重叠。lockPiece把当前方块写入m_board然后调clearLines消行再spawnPiece生成下一个。onTick是定时器槽函数每次触发尝试下移一格如果碰撞就锁定并生成新方块。提示m_board用int而不是bool是为了存颜色索引。如果你只关心有无方块用bool也行但后面画图时还要额外查颜色表不如直接存索引。2.4 七种方块的形状表示与旋转实现俄罗斯方块有七种标准形状I、O、T、S、Z、J、L。每种形状用 4x4 矩阵表示最方便旋转因为旋转 90 度就是矩阵转置加翻转不需要处理非正方形矩阵的边界问题。// tetromino.h #pragma once #include array #include QColor enum class PieceType { I, O, T, S, Z, J, L, Count }; class Tetromino { public: Tetromino(); void setType(PieceType type); void rotate(); int x() const { return m_x; } int y() const { return m_y; } void move(int dx, int dy) { m_x dx; m_y dy; } QColor color() const; std::arraystd::arrayint, 4, 4 cells() const { return m_cells; } private: std::arraystd::arrayint, 4, 4 m_cells{}; PieceType m_type; int m_x 3; int m_y 0; };m_cells是 4x4 矩阵1 表示该位置有方块0 表示空。rotate的实现void Tetromino::rotate() { std::arraystd::arrayint, 4, 4 rotated{}; for (int r 0; r 4; r) for (int c 0; c 4; c) rotated[c][3 - r] m_cells[r][c]; m_cells rotated; }这是顺时针旋转 90 度的标准写法新矩阵的第c行第3-r列等于原矩阵第r行第c列。O 型方块旋转后不变但代码不需要特判因为 4x4 矩阵旋转四次会回到原状逻辑统一。setType根据类型填充m_cells。以 T 型为例void Tetromino::setType(PieceType type) { m_type type; m_cells {}; switch (type) { case PieceType::T: m_cells[0][1] 1; m_cells[1][0] 1; m_cells[1][1] 1; m_cells[1][2] 1; break; // 其他形状类似填充 default: break; } m_x 3; m_y 0; }初始m_x 3是为了让方块出现在棋盘中间偏左的位置因为 4x4 矩阵的有效区域通常在第 1 到第 2 列。m_y 0表示从顶部开始下落。2.5 定时器驱动与键盘事件让方块动起来Qt 的QTimer是驱动游戏循环的核心。在GameBoard构造函数里创建定时器并连接槽函数GameBoard::GameBoard(QWidget *parent) : QWidget(parent) { setFocusPolicy(Qt::StrongFocus); m_timer new QTimer(this); connect(m_timer, QTimer::timeout, this, GameBoard::onTick); m_timer-start(INIT_INTERVAL); spawnPiece(); }setFocusPolicy(Qt::StrongFocus)必须设置否则GameBoard收不到键盘事件。onTick的逻辑void GameBoard::onTick() { if (m_paused || m_gameOver) return; if (!checkCollision(m_current, 0, 1)) { m_current.move(0, 1); } else { lockPiece(); clearLines(); spawnPiece(); if (checkCollision(m_current, 0, 0)) { m_gameOver true; m_timer-stop(); } } update(); }update()触发paintEvent重绘。键盘事件处理void GameBoard::keyPressEvent(QKeyEvent *event) { if (m_gameOver) return; switch (event-key()) { case Qt::Key_Left: if (!checkCollision(m_current, -1, 0)) m_current.move(-1, 0); break; case Qt::Key_Right: if (!checkCollision(m_current, 1, 0)) m_current.move(1, 0); break; case Qt::Key_Down: if (!checkCollision(m_current, 0, 1)) m_current.move(0, 1); break; case Qt::Key_Up: m_current.rotate(); if (checkCollision(m_current, 0, 0)) m_current.rotate(); // 旋转后碰撞就转回去 break; case Qt::Key_Space: while (!checkCollision(m_current, 0, 1)) m_current.move(0, 1); break; case Qt::Key_P: m_paused !m_paused; break; default: QWidget::keyPressEvent(event); } update(); }旋转后如果碰撞直接再旋转一次转回去这是最简单的「后悔药」实现。更精细的做法是尝试左右偏移但代码量会翻倍新手先跑通基础版再说。2.6 绘制逻辑QPainter 画格子与消行动画paintEvent负责画棋盘、当前方块、网格线。核心代码void GameBoard::paintEvent(QPaintEvent *) { QPainter painter(this); painter.fillRect(rect(), Qt::black); // 画已锁定方块 for (int r 0; r ROWS; r) { for (int c 0; c COLS; c) { if (m_board[r][c] ! 0) { QRect cell(c * CELL_SIZE, r * CELL_SIZE, CELL_SIZE, CELL_SIZE); painter.fillRect(cell, QColor::fromHsv((m_board[r][c] * 40) % 360, 200, 200)); painter.setPen(Qt::darkGray); painter.drawRect(cell); } } } // 画当前方块 auto cells m_current.cells(); for (int r 0; r 4; r) { for (int c 0; c 4; c) { if (cells[r][c]) { int boardX (m_current.x() c) * CELL_SIZE; int boardY (m_current.y() r) * CELL_SIZE; QRect cell(boardX, boardY, CELL_SIZE, CELL_SIZE); painter.fillRect(cell, m_current.color()); painter.setPen(Qt::darkGray); painter.drawRect(cell); } } } // 画网格线 painter.setPen(QColor(40, 40, 40)); for (int c 0; c COLS; c) painter.drawLine(c * CELL_SIZE, 0, c * CELL_SIZE, ROWS * CELL_SIZE); for (int r 0; r ROWS; r) painter.drawLine(0, r * CELL_SIZE, COLS * CELL_SIZE, r * CELL_SIZE); }QColor::fromHsv根据棋盘存的值生成不同颜色避免额外维护颜色表。drawRect画边框让每个格子有区分度。网格线用暗灰色不抢方块视觉。消行动画可以简单做在clearLines里先标记要消的行然后启动一个短定时器把这几行闪两下再真正移除。但为了代码简洁第一版直接移除后面再优化。2.7 分数、等级与下落速度的联动分数规则消一行 100 分两行 300 分三行 500 分四行 800 分。等级每消 10 行升一级下落间隔减少 50 毫秒最低不低于 100 毫秒。void GameBoard::clearLines() { int linesCleared 0; for (int r ROWS - 1; r 0; --r) { bool full true; for (int c 0; c COLS; c) { if (m_board[r][c] 0) { full false; break; } } if (full) { linesCleared; for (int rr r; rr 0; --rr) for (int c 0; c COLS; c) m_board[rr][c] m_board[rr - 1][c]; for (int c 0; c COLS; c) m_board[0][c] 0; r; // 重新检查当前行 } } if (linesCleared 0) { static const int scores[] {0, 100, 300, 500, 800}; m_score scores[linesCleared]; m_level 1 m_score / 1000; m_interval qMax(100, INIT_INTERVAL - (m_level - 1) * 50); m_timer-setInterval(m_interval); } }m_timer-setInterval动态调整下落速度。注意r那行消行后上面的行会下移当前行需要重新检查所以循环变量要回退一格。注意clearLines里用了static const int scores[]数组大小是 5索引 0 到 4。linesCleared最大是 4不会越界。如果你改成其他计分规则记得同步改数组大小。3. 避坑与排查Qt 俄罗斯方块开发中最容易翻车的 5 个点3.1 键盘没反应焦点策略与事件过滤现象程序跑起来方块能自动下落但按左右键没反应。原因GameBoard没有设置setFocusPolicy(Qt::StrongFocus)或者焦点被其他控件抢走了。如果主窗口里放了按钮或输入框它们会默认获取焦点。解决在GameBoard构造函数里加setFocusPolicy(Qt::StrongFocus)并且在MainWindow显示后调用gameBoard-setFocus()。如果还是不行检查有没有在MainWindow里重写keyPressEvent但没有调用父类实现导致事件被吞掉。3.2 方块旋转后穿墙或重叠现象按上键旋转方块跑到棋盘外面或者和已有方块叠在一起。原因旋转后没有做碰撞检测或者检测逻辑只检查了边界没检查已有方块。解决旋转后立即调checkCollision(m_current, 0, 0)如果返回 true 就再旋转一次转回去。更完善的做法是尝试左移或右移一格再检测这叫「踢墙」但第一版可以不做。checkCollision里要同时判断x c 0、x c COLS、y r ROWS以及m_board[y r][x c] ! 0。3.3 消行后分数不更新或等级不升现象消了行但分数没变下落速度也没加快。原因clearLines里linesCleared统计逻辑有误或者m_timer-setInterval没调用。解决在clearLines末尾加qDebug() lines: linesCleared score: m_score;确认统计值。如果linesCleared始终为 0检查内层循环的full判断是不是写成了if (m_board[r][c])而不是if (m_board[r][c] 0)。等级更新后一定要调m_timer-setInterval(m_interval)否则定时器还是用旧间隔。3.4 编译报错 unknown module(s) in qt: widgets现象CMake 配置阶段报:-1: error: unknown module(s) in qt: widgets或cannot find -lpublic。原因find_package里写的 Qt 版本和实际安装的不一致或者target_link_libraries里模块名拼错。解决确认 Qt 安装版本。Qt 6 用find_package(Qt6 COMPONENTS Widgets REQUIRED)和Qt6::WidgetsQt 5 用find_package(Qt5 COMPONENTS Widgets REQUIRED)和Qt5::Widgets。如果同时装了 Qt 5 和 Qt 6在 CMake 命令里指定-DCMAKE_PREFIX_PATH/path/to/Qt/6.x.x/gcc_64。不要混用版本否则会出现fatal: cannot mix incompatible qt library这类运行时错误。3.5 程序发布后在其他机器上闪退现象在自己电脑上跑得好好的拷到别人电脑上双击就闪退报qt.qpa.plugin: could not find the qt platform plugin windows。原因Qt 的动态库和平台插件没有一起打包。解决Windows 上用windeployqt TetrisQt.exe自动拷贝依赖Linux 上用linuxdeployqt或手动拷贝platforms/libqxcb.so。如果目标机器是树莓派交叉编译后要把plugins/platforms/libqlinuxfb.so一起拷过去并设置QT_QPA_PLATFORMlinuxfb。发布前在干净虚拟机上测一遍别等用户反馈才发现。4. 从能跑到好用三个提升手感的细节与验证方法4.1 用 QSS 美化界面并验证样式生效Qt 支持用 QSSQt Style Sheets给控件加样式。在main.cpp里加载QFile styleFile(:/styles.qss); if (styleFile.open(QFile::ReadOnly)) { qApp-setStyleSheet(styleFile.readAll()); }styles.qss内容示例QMainWindow { background-color: #1e1e1e; } QLabel { color: #e0e0e0; font-size: 16px; padding: 4px; }验证方法改一下QLabel的font-size重新编译运行看分数标签的字号有没有变。如果没变检查.qrc文件里有没有把styles.qss加进去路径写的是不是:/styles.qss。QSS 的坑在于选择器优先级QMainWindow QLabel比QLabel优先级高如果样式不生效先看有没有更具体的选择器覆盖了。4.2 用 QTest 写一个自动下落测试Qt 自带 QTest 框架可以给游戏逻辑写单元测试。在CMakeLists.txt里加find_package(Qt6 COMPONENTS Test REQUIRED) enable_testing() add_executable(tst_tetris tests/tst_tetris.cpp src/gameboard.cpp src/tetromino.cpp) target_link_libraries(tst_tetris PRIVATE Qt6::Widgets Qt6::Test) add_test(NAME tst_tetris COMMAND tst_tetris)测试用例void TestTetris::testCollision() { GameBoard board; Tetromino piece; piece.setType(PieceType::I); QVERIFY(!board.checkCollision(piece, 0, 0)); QVERIFY(board.checkCollision(piece, -10, 0)); }checkCollision需要改成public或者用friend声明才能被测试访问。跑ctest --test-dir build看结果。这一步能帮你提前发现边界判断的 off-by-one 错误比手动玩半天靠谱。4.3 用 QElapsedTimer 测帧率并调优重绘如果方块多了之后感觉卡顿用QElapsedTimer测paintEvent耗时void GameBoard::paintEvent(QPaintEvent *) { QElapsedTimer timer; timer.start(); // ... 绘制代码 ... qDebug() paint cost: timer.nsecsElapsed() / 1000000.0 ms; }正常应该在 1 到 3 毫秒之间。如果超过 10 毫秒检查是不是每次都在paintEvent里创建QColor或QPen对象。把这些对象提到类成员里初始化或者用static局部变量能减少重复构造开销。另一个优化点是只重绘变化区域用update(QRect)代替update()但俄罗斯方块全屏重绘成本本来就不高优先保证代码可读性。我自己的习惯是每加一个新功能先跑一遍 QTest再用QElapsedTimer看绘制耗时最后在树莓派上交叉编译跑一次。树莓派的 GPU 弱如果那里不卡桌面端基本没问题。这套流程帮我省了很多次「在我电脑上好好的」的尴尬。希望帮到你。本文还有配套的精品资源点击获取
返回列表