简介:基于Linux、QT、C++实现“别踩白块儿”小游戏的完整可编译工程源码,面向Qt初学者与游戏开发爱好者,适合作为课程设计或入门练手项目。压缩包共52个文件,包含34张PNG与2张JPG图片素材、6个C++源文件、5个头文件、2个UI界面文件,另有pro工程文件、qrc资源文件等配置,整体仅736KB,文件组织清晰。已有1182人下载学习。项目实现4×4游戏界面、30秒倒计时、得分与历史最高分记录,利用qrand时间种子生成随机黑块位置,通过定时器每100ms刷新时间,并基于工厂模式与queue队列管理方块的生产、消除与整体下落,代码模块划分明确。附带的UI文件和图片素材可直接复用,能帮助读者理解Qt界面布局、信号槽机制、定时器事件响应以及工厂模式等设计思想。
1. 先别急着写游戏逻辑:这个标题背后是一整条 Linux + Qt 技术栈
我第一次在 Ubuntu 上跑用 QT 写的“别踩白块儿”小游戏,不是被游戏逻辑卡住的,而是被一个最基础的工程问题卡住的:qmake 编完,运行时报出qt.qpa.plugin: could not find the Qt platform plugin "linuxfb",我当时盯着终端愣了半分钟。后来才明白,这个标题看着是三个词拼一起,实际是一条完整的落地链路:Linux 下的 Qt 环境怎么搭、C++ 怎么组织类和事件、方块下落与点击判定怎么做。它适合两类人:一类是刚学完 C++ 基础、想用一个小游戏把类和事件循环串起来的新手;另一类是准备把游戏搬到树莓派或嵌入式触屏上的开发者,因为这套代码从桌面换到真机,往往只差一个平台插件和坐标策略。下面我把机制、源码、编译、踩坑一次说清楚。
2. 游戏底层机制:事件循环、坐标判定与随机数的取舍
2.1 游戏循环与定时器:为什么一个 QTimer 就够,别开线程
很多人一听到“游戏”,本能地想到QThread开一个循环去驱动方块下落。这是我见过最多的过度设计。Qt 里有一个非常关键的前提:所有 UI 操作必须发生GUI线程的事件循环里,而QTimer本身就是事件循环的一部分。
QTimer的本质不是开线程,而是往事件队列里注册一个定时触发的事件。它到点后发出timeout信号,由事件循环在当前线程里执行连接的槽函数。对“别踩白块儿”这类界面型小游戏来说,一帧更新一次位置即可,CPU 压力几乎可以忽略。我用QTimer驱动一个updateGame()槽函数,每 30ms 动一次方块,这个频率足够流畅,又不会把 CPU 占满。
用QThread的问题在于:子线程不能直接操作QWidget,你需要在子线程里算好坐标,再通过信号槽把结果传回主线程。如果游戏逻辑简单到只需要几十行代码,这种架构只会引入无谓的同步和崩溃点。除非你的版本里有复杂的寻路、物理模拟,否则我建议一律用QTimer。
提示:Linux 桌面环境下
QTimer的精度大约在 1~10ms 之间,受系统定时器粒度影响。对于 30ms 一帧的方块游戏完全够用;但如果你是做高精度物理模拟,这个 timer 就不够看了。
2.2 坐标系统:逻辑坐标、物理坐标与分辨率缩放
写这个小游戏一定会碰到坐标问题。QMouseEvent::pos()返回的是当前 widget 内的逻辑坐标,而不是屏幕全局坐标。这个坐标直接对应paintEvent里QPainter绘制用的坐标系,常规桌面环境下两者一致,直接相除就能算出格子。
真正容易翻车的是高 DPI 缩放。如果 Linux 桌面开启了全局缩放,Qt 会按devicePixelRatio对逻辑坐标系做缩放,绘制和鼠标坐标通常会被 Qt 自己换算好;但如果你混合使用了全局坐标QCursor::pos()和局部坐标event->pos(),就会出现点击位置和黑块对不上的玄学问题。我的处理原则是:所有判定只用event->pos(),不做二次换算。这样在桌面 X11 和嵌入式linuxfb上的行为一致。
坐标算格子的方式很简单:用点击位置除以格子宽高,得到行和列。窗口固定为 400x600,四列五行的棋盘,每个格子就是 100x120。整数除法天然带有向下取整,可以避免点击到边界时算错行。
2.3 随机数别再用 rand():为什么直接用 QRandomGenerator
C++ 初学者习惯用rand() % 4来生成 0~3 的随机列,这在写关联性强的游戏时有两个隐患:一是rand()默认的线性同余序列周期短,连续生成的序列会有肉眼可察觉的规律;二是多线程场景下rand()的全局状态会让序列不确定。Qt 项目里更干净的做法是直接用QRandomGenerator。
QRandomGenerator::global()返回一个进程级的随机数对象,线程安全,内部会结合系统熵源,bounded(4)语义是返回[0, 4)的均匀分布整数,正好对应四列。每次生成新行黑块时调用一次,代码干净,也避免了手动srand(time(NULL))这种容易踩坑的写法。
int randomCol = QRandomGenerator::global()->bounded(4); // 0~3,对应四列如果不想依赖 Qt 的随机接口,C++11 的<random>用std::mt19937也完全可以,两者选一个,不要混用。我在实际项目中之所以倾向 Qt 接口,是因为少一个std::uniform_int_distribution的构造和状态维护成本,对这个小游戏来说更直观。
3. 在 Linux 上把 qmake 工程跑起来:pro 文件、编译命令与三个常见报错
3.1 最小的 qmake 工程结构与 pro 文件
推荐用 qmake 而不用 CMake,原因很简单:Qt 官方工具链默认就带 qmake,初学者少学一套构建系统。工程目录只有三个文件:whiteblock.pro、main.cpp、game.cpp、game.h。虽然你后面会加头文件和源文件,但这个规模已经能撑起整个游戏。
whiteblock.pro是最小配置,写清楚QT += widgets和源文件列表,qmake 会自动处理头文件的依赖关系。注意CONFIG += c++11这一行,它让编译时启用 C++11 标准,QRandomGenerator和QVector的现代接口都需要它。如果系统里同时装了 Qt4 和 Qt5,pro 文件里的widgets模块名也是区别两者的重要标志,Qt4 里没有这个模块。
QT += widgets TARGET = whiteblock TEMPLATE = app CONFIG += c++11 SOURCES += main.cpp game.cpp HEADERS += game.h3.2 编译与运行的完整命令
Linux 下编译 Qt 工程是一个两步走的过程:先qmake生成 Makefile,再make编译链接。这里有一个新手常犯的错:在源码目录直接运行 qmake,然后编译产物和源码混在一起。我习惯建一个build目录,把生成物隔离出去,后面想 clean 直接删目录即可,这是 Linux 上比较主流的做法。
mkdir build && cd build qmake .. # 生成 Makefile make -j4 # 4 线程并行编译 ./whiteblock # 运行程序几个值得留意的参数:qmake后面跟的路径是 pro 文件所在目录,所以是qmake ..;-j4是让 make 用 4 个任务并行编译,机器核数越多越明显;如果 pro 文件不在上一级目录,换成具体路径即可。编译过程中看到 warning 不要慌,只要最后生成whiteblock可执行文件就算成功。
3.3 版本一致性:别再被 qmake 与运行库的兼容问题折磨
Linux 发行版经常同时存在多个 Qt 版本,比如/usr/lib/x86_64-linux-gnu/qt4/bin/qmake和/usr/lib/qt5/bin/qmake。如果你用旧版 qmake 生成 Makefile,再用新版的头文件和库去编译链接,就很容易出现终端里报cannot mix incompatible Qt library (version 0x50601) with this library这类兼容错误。
这行报错的意思是:某个库文件是用 Qt 5.6.1 编译的,但你现在链接的库或头文件是另一个版本。解决思路不是去搜“哪个版本匹配”,而是先确认自己到底在用哪个 qmake:
qmake -v # 查看 qmake 版本 qmake -query QT_VERSION # 以 qmake 内置方式输出版本号 ldd ./whiteblock # 查看最终可执行文件链接了哪些 Qt 库如果qmake -v显示的是 Qt 5.15 而ldd里有 Qt 5.6 路径,就是 PATH 和 LD_LIBRARY_PATH 混了。解决方法是把路径指干净:用发行版自带的 qmake 工具选择器,或者彻底移除旧版 Qt 的 bin 目录,然后重新生成 Makefile。这类问题我遇到不下三次,每次的教训都是:装完 Qt 先把qmake -v输出保存下来,作为排错基准。
4. 核心实现:自绘棋盘、行推进逻辑与点击判定
4.1 用 QPushButton 还是 QPainter 自绘?
新手最容易想到的做法是拖一个 4x5 的QPushButton网格,每个按钮代表一个格子。这个方案思路没错,但会带来两个坑:第一,你需要手动维护 20 个按钮的 black/white 状态,每次下一行走一行时,要么改变背景色要么移动位置,逻辑非常别扭;第二,点击空白的判定做不到——用户点到白块区域时,你需要判断这一击落在哪个按钮上,而按钮消费了事件,你不得不在每个按钮的槽函数里转一道。
我的选择是QPainter 自绘整个棋盘:一个QWidget,重写paintEvent和mousePressEvent,黑块的位置用一个QVector<int>存列号,绘制时根据列号和行号画矩形。这样状态数据结构极简,判定直接做坐标除法,不用维护 20 个控件。Qt 的QPainter画一个纯色矩形开销很小,远远低于创建和管理多个按钮控件,所以在这类动态棋盘场景里,自绘是更长远、更高效的方案。
4.2 核心代码:行推进模式的完整实现
这套游戏逻辑采用“行推进”模式:棋盘显示 5 行,每行只有一个黑块,玩家点击底部一行的黑块即得分,然后整版向上推进一行,顶部补一个新随机行。一旦点击了非底部行、白块区域,立即游戏结束。这种模式还原度最高,实现却非常短。
game.h声明核心成员,注意Q_OBJECT宏必须保留,它让类支持 Qt 的信号槽和无对象树机制:
#ifndef WHITEBLOCKGAME_H #define WHITEBLOCKGAME_H #include <QWidget> #include <QVector> class WhiteBlockGame : public QWidget { Q_OBJECT public: explicit WhiteBlockGame(QWidget *parent = nullptr); protected: void paintEvent(QPaintEvent *event) override; void mousePressEvent(QMouseEvent *event) override; private: void newGame(); QVector<int> m_rows; // 每行黑块所在列的列号,范围 0~3 int m_score; // 已踩块数 bool m_running; int m_cellWidth; // 每列宽度 int m_cellHeight; // 每行高度 int m_cols; int m_visibleRows; }; #endif // WHITEBLOCKGAME_Hgame.cpp是核心实现。构造时固定窗口尺寸,newGame初始化随机行数据。注意QRandomGenerator::global()->bounded()返回的是整数,直接赋值给 int 作为列号。
#include "game.h" #include <QPainter> #include <QMouseEvent> #include <QRandomGenerator> WhiteBlockGame::WhiteBlockGame(QWidget *parent) : QWidget(parent) , m_score(0) , m_running(false) , m_cellWidth(100) , m_cellHeight(120) , m_cols(4) , m_visibleRows(5) { setFixedSize(m_cellWidth * m_cols, m_cellHeight * m_visibleRows); setWindowTitle(QStringLiteral("别踩白块儿")); newGame(); } void WhiteBlockGame::newGame() { m_rows.clear(); for (int i = 0; i < m_visibleRows; ++i) m_rows.append(QRandomGenerator::global()->bounded(m_cols)); m_score = 0; m_running = true; update(); }点击判定的实现是整个游戏的关键。先用整除得到行列号,然后判断是否点中底部行且列号一致。注意event->pos().y() / m_cellHeight的整数除法天然取整,点击在底部行内部任何位置都能正确计算出行号。未命中的任何情况都视为游戏结束,这符合“踩白块”的核心惩罚机制。
void WhiteBlockGame::mousePressEvent(QMouseEvent *event) { if (!m_running) return; int row = event->pos().y() / m_cellHeight; int col = event->pos().x() / m_cellWidth; if (row >= 0 && row < m_visibleRows && col >= 0 && col < m_cols) { if (row == m_visibleRows - 1 && col == m_rows.last()) { m_rows.removeLast(); m_rows.prepend(QRandomGenerator::global()->bounded(m_cols)); ++m_score; update(); return; } } m_running = false; // 点错或点白块都结束 update(); }绘制代码直接以行列号定位矩形坐标。底部的目标行就是第m_visibleRows - 1行,为了让玩家一眼看清,我给底部黑块画了红色描边;其他行黑块保持纯黑。网格线用浅灰色细线区分格子,这是 QPainter 的基础用法,却能让棋盘视觉效果清晰很多。
void WhiteBlockGame::paintEvent(QPaintEvent *) { QPainter painter(this); // 背景填浅灰,棋盘更清晰 painter.fillRect(rect(), QColor(0xF0, 0xF0, 0xF0)); // 逐行绘制黑块与网格线 painter.setPen(QPen(QColor(0xD0, 0xD0, 0xD0), 1)); for (int i = 0; i < m_visibleRows; ++i) { int x = m_rows[i] * m_cellWidth; int y = i * m_cellHeight; painter.fillRect(x, y, m_cellWidth, m_cellHeight, Qt::black); // 当前目标行(底部第一行)加红色边框,提示玩家点这里 if (i == m_visibleRows - 1) painter.setPen(QPen(QColor(0xCC, 0x33, 0x33), 2)); else painter.setPen(QPen(QColor(0xD0, 0xD0, 0xD0), 1)); painter.drawRect(x, y, m_cellWidth, m_cellHeight); } }main.cpp负责创建 QApplication 和主窗口,几乎每个 Qt Widgets 程序都是同样的骨架:
#include <QApplication> #include "game.h" int main(int argc, char *argv[]) { QApplication app(argc, argv); WhiteBlockGame w; w.show(); return app.exec(); }4.3 核心可调参数:格子尺寸、列数与节奏手感
上面代码里有四个直接影响手感的参数:m_cellWidth、m_cellHeight、m_cols、m_visibleRows。我给出的 100x120 是针对 400x600 窗口调的,鼠标点击精准,触屏上 100px 宽的格子也足够友好。如果你的目标平台是手机或小平板,格子高度可以调到 150px,行数减少到 4 行,降低误触率;如果横向比较宽,可以列数加到 5,难度明显提升。
这些参数最好都定义为成员变量,而不是散落在绘制和判定代码里的魔法数字。行推进模式没有定时驱动,游戏难度完全由玩家操作速度决定,所以不存在调速度参数的问题;但如果你想做自动下落版本,只需要把QTimer加进去,每 30ms 把所有黑块坐标向下移动几个像素,同样是这套数据结构的自然扩展。
5. 避坑:Linux 下跑 Qt 小游戏遇到的 5 个高频问题
5.1 运行时找不到平台插件:qt.qpa.plugin 报错怎么办
现象:程序编译成功,运行时报qt.qpa.plugin: could not find the Qt platform plugin "linuxfb",程序直接退出。很多初学者遇到这个报错的第一反应是重装 Qt,其实问题通常出在平台插件路径。
原因:Qt 通过插件机制访问底层图形系统,linuxfb是嵌入式 Linux 下的 framebuffer 平台插件,桌面发行版默认不安装或未配置该插件。我遇到最多的情况是用户在命令行手动设置了QT_QPA_PLATFORM=linuxfb,但系统里根本没装对应插件包。
解决:桌面环境下强制指定 xcb 插件运行,或直接不设置环境变量让 Qt 自动探测。嵌入式环境则安装对应平台插件包,并检查插件目录与编译时是否一致。
QT_QPA_PLATFORM=xcb ./whiteblock # 桌面 X11 环境 QT_QPA_PLATFORM=offscreen ./whiteblock # 无显示环境快速冒烟测试5.2 版本混乱:cannot mix incompatible Qt library 系列报错
现象:编译或运行时出现cannot mix incompatible Qt library (version 0x50601) with this library,且后面的版本号和你预期的不一致。
原因:编译时头文件来自 Qt 5.6.1,但链接的库来自 Qt 5.15,或者反过来。Linux 系统里存留多个 Qt 版本是常态,尤其是用包管理器装过一个、又手动编译过一个之后,PATH 环境变量会决定 qmake 指向哪个版本。
解决:先用qmake -v确定当前 qmake 版本,再用ldd ./whiteblock看运行时链接路径。发现不一致时,在 build 目录重新运行正确版本的 qmake 并重新 make,不要手动复制库文件。这个问题的根本解法是统一工具链版本,而不是删库重装。
5.3 点击位置和黑块对不上:高 DPI 缩放吃掉坐标
现象:鼠标点击的位置与实际绘制的黑块位置偏差明显,尤其是点击越靠近窗口边缘偏差越大。
原因:桌面环境启用了缩放后,Qt 的逻辑坐标系和物理像素之间出现换算。如果绘制代码使用了width()和height(),而判定代码使用QCursor::pos()这个全局坐标,不经过mapFromGlobal()转换,偏差就会出现在点击判定上。
解决:所有点击判定统一使用event->pos(),这是已经映射到本地坐标系的值。如果需要获取绝对坐标点,先mapFromGlobal再做除法,不要在判定里混用两套坐标体系。高 DPI 场景下,也可以用devicePixelRatioF()显式计算逻辑像素。
5.4 定时器不触发:QTimer 的几个低级错误
现象:在自动下落版本里,QTimer设了 30ms,但方块一动不动,或者程序运行几秒后崩溃。
原因:最常见的是把QTimer声明成了栈上局部变量,函数结束后 timer 被销毁,事件循环里自然没有事件;其次是忘记connect,或者 connect 时用了错误的信号函数签名。
解决:把 timer 声明为成员变量,用new QTimer(this)创建并指定 parent,这样它会随窗口一起销毁;connect 统一用&QTimer::timeout新语法,编译期就能检查错误。如果你设置setInterval(30)后还是不动,第一件事是在槽函数里加 qDebug 打印,排查事件是否真的触发了。
5.5 触屏设备上点击没反应:触摸事件与鼠标事件
现象:在树莓派或带触摸屏的嵌入式板子上运行,触摸屏幕没有任何反应,鼠标点击却正常。
原因:部分嵌入式 Linux 触摸驱动只上报触摸事件,不会自动合成鼠标事件;而 Qt 的mousePressEvent默认只接收鼠标事件,触摸事件需要单独处理或人工合成。
解决:在构造时设置setAttribute(Qt::WA_AcceptTouchEvents),并重写event()方法接收QTouchEvent,从中提取触点坐标后转成和mousePressEvent相同的处理逻辑。这是一个典型的嵌入式 Qt 问题,桌面开发时不容易暴露。
bool WhiteBlockGame::event(QEvent *e) { if (e->type() == QEvent::TouchBegin || e->type() == QEvent::TouchUpdate) { QTouchEvent *te = static_cast<QTouchEvent *>(e); if (!te->touchPoints().isEmpty()) { QPointF pos = te->touchPoints().first().pos(); handleClick(pos.toPoint()); // 把坐标传给统一的判定函数 return true; } } return QWidget::event(e); }6. 从桌面到触屏:用数据验证玩法手感,把最后一公里做好
棋盘的代码跑通只是第一步,真正决定这个小游戏能不能拿得出手的是手感。我的习惯是在类里加一个调试模式:显示实时得分、最近 50 次点击的平均反应时间、误触率。这样不需要猜,用数据就能看出格子高度是否合理、判定区域是否需要收窄。
// 在 paintEvent 里打印调试数据 painter.setPen(Qt::black); painter.drawText(rect().adjusted(5, 5, -5, -5), QStringLiteral("分数: %1 平均反应: %2ms") .arg(m_score) .arg(m_avgReactionMs));具体调参时,我会用QElapsedTimer记录每次成功点击的间隔,如果平均反应时间低于 250ms,说明格子排布太密、玩家没有思考时间;如果高于 700ms,游戏偏无聊,可以增加列数到 5 或把格子高度缩小到 100px。触屏平台上还要考虑触摸事件延迟,很多廉价电容屏的采样率只有 60Hz,判定区域建议加大 10~20 像素的 padding,否则玩家总觉得“点了没反应”。
发布阶段,桌面环境用 xcb 插件跑,嵌入式环境记得交叉编译 Qt 时把linuxfb插件带上,并在启动脚本里指定QT_QPA_PLATFORM=linuxfb。无头服务器上想自动截图验证 UI,用offscreen平台就能跑出完整界面。每次改完参数后,我都会在真机上连续玩三分钟,记录漏踩次数,直到稳定在每分钟 2 次以下才算通过。这个小项目虽然只有几百行代码,但它把事件循环、坐标计算、随机生成、绘图和平台差异全部练了一遍。希望这份经验能帮你在 Linux 上少走几个我走过的弯路。
本文还有配套的精品资源,点击获取