☰
基于Qt/C++复刻“别踩白块儿”的Linux实战笔记
2026/10/5 5:36:14 网站建设 项目流程

简介:基于 Linux、Qt 与 C++ 实现的“别踩白块儿”小游戏,面向有基础 C++ 语法、想进阶 Qt 界面开发与游戏逻辑的读者。压缩包含 52 个文件,约 736KB,其中 34 张 PNG 图片用于按钮与背景,6 个 C++ 源文件和 5 个头文件实现核心逻辑,另有 2 个界面文件、2 张 JPG 背景图及 Qt 工程与资源文件。游戏实现 4×4 黑白块界面、30 秒倒计时、得分记录与历史最高分显示。黑块位置以时间为种子随机生成,并对 4 取余保证每行仅一个黑块;定时器每 100 毫秒刷新时间。程序采用工厂模式生成黑白块,用队列容器管理方块;点击黑块后删除并弹出队首四个块再补充新块,同时更新所有块纵坐标。代码结构清晰,覆盖随机数、定时器、工厂模式、队列容器和 Qt 事件处理等知识点,适合作为 Linux 下 C++ 游戏开发入门参考。已有 1182 人学习下载。

1. 为什么我会用 Linux、Qt、C++ 去复刻“别踩白块儿”

写这篇笔记的缘起,是我在 Ubuntu 22.04 上用 Qt 5.15.2 和 C++17 复刻了“别踩白块儿”,从最简单的单文件 main.cpp 一路改到带暂停、计分、音效和动画的小程序。这个项目非常适合练手:它麻雀虽小,却把事件循环、状态机、QPainter 绘图、定时器精度、触摸事件和界面分层全部串了起来。如果你正在学 Qt 或者想给自己的嵌入式 Linux 设备找一个图形界面示例,这篇实战笔记应该能帮你少走一个月的弯路。

我打算先讲透开局半小时就该定下的技术选型,然后直接贴出能跑的代码,再围绕游戏循环、碰撞判定、状态切换、高分存取和 Android 部署这几个关键点展开。整个项目只有一个 CMake 工程,文件和思路都保持小型化,方便你随时在板子上或者桌面上复现。

2. QPushButton 还是 QWidget?为什么我最后选了自绘方案

2.1 别踩白块儿的本质是什么

这个游戏从玩法上看是“在规定时间内避开黑块、只点白块”,但从程序实现的角度看,它就是一个单向滚动的序列:

  • 屏幕被纵向分成四列(或三列、五列,按难度定);
  • 每一行里有且只有一个黑块,其他都是白块;
  • 黑色块整体向上滚动,等价于你的“视点”向下移动;
  • 玩家点击白块 = 得分,点击黑块 = 游戏结束。

所以,先别急着写界面。把游戏抽象成一个BlockRow数组,每个元素记录三件事:所在行号、黑块在哪一列、当前滚动位移。Qt 的 QTimer 每 16ms 触发一次 tick,把所有 row 的 y 坐标整体递增,就得到了滚动效果。这个抽象越早做,后面的架构越省事。

我第一次做的时候,第一版用四个 QPushButton 平铺模拟四列,靠 setText 和样式表去切换黑白。结果帧率稍微一高,按钮闪烁和重绘开销就压不住了。后来我改成用一个 QWidget 子类重写paintEvent()一次画全屏,代码量反而少了一半。

2.2 为什么不用 QML

这个标题里写了 Qt,很多人第一反应是 Qt Quick / QML,但在 Linux 上用 C++ 写传统小游戏,我强烈建议先用 Widgets 方案。原因有四个:

  • 部署简单:一个 QWidget 程序编译出来是单个二进制,不需要带 QML 引擎和 qrc 里的解释器运行时;
  • 调试直观:断点打在paintEvent()里,能看到每个块的坐标和颜色,QPainter 的绘制顺序也完全可控;
  • 笔试面试常见:很多 C++ / Qt 岗位的机试都要求用 Widgets 写一个小程序,QML 反而不在默认环境里;
  • 触摸移植成本低:QWidget 默认支持鼠标事件,Mobile 上再补一个 TouchEvent 转换即可。

如果你是抱着学习 Qt 事件循环和绘制机制的目的来的,Widgets 方案能让你把每一行 QPainter 代码都吃得明明白白。QML 那个声明式写法,更适合做动态界面和流畅动画,但“别踩白块儿”这种逻辑密度高的游戏,用命令式代码维护起来更顺手。

2.3 我最终整理的最小工程结构

dont_touch_white/ ├── CMakeLists.txt ├── main.cpp # 入口 ├── GameWidget.h/.cpp # 游戏窗口:绘制 + 事件 + 状态 ├── GameState.h # 游戏状态枚举 └── settings.h/.cpp # 高分持久化,用 QSettings

main.cpp 里只做一件事:构造 QApplication,再 new 一个 GameWidget 显示。不要在 main 里塞游戏逻辑,这是 Qt 项目的基本体面。

#include <QApplication> #include "GameWidget.h" int main(int argc, char *argv[]) { QApplication app(argc, argv); GameWidget w; w.show(); return app.exec(); }

这段代码的要点是:Qt5 以后不再需要QT += widgets写在 pro 文件里,而是由 CMake 的find_package(Qt5 COMPONENTS Widgets)负责。只要 CMake 能找到 Qt 库,这一层就稳了。

2.4 CMake 里最容易翻车的一个开关

cmake_minimum_required(VERSION 3.16) project(dont_touch_white LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_AUTOMOC ON) find_package(Qt5 5.15 REQUIRED COMPONENTS Widgets) add_executable(dont_touch_white main.cpp GameWidget.cpp settings.cpp ) target_link_libraries(dont_touch_white PRIVATE Qt5::Widgets)

这个CMAKE_AUTOMOC ON是早期我踩过的坑。如果关掉它,凡是带Q_OBJECT的类在编译时会报unknown type name 'Q_OBJECT'或者链接时找不到 vtable 之类的诡异错误。其实那不是 Qt 的问题,而是 MOC 没跑。

另外Qt5 5.15 REQUIRED里这个 5.15 不是版本号,是组件的“版本下限检查”,写成 5.5 也能编过。真正决定你用哪个 Qt 的,是你系统里安装的qt5-default或qtbase5-dev的版本。Ubuntu 22.04 默认源里是 5.15.3 或 5.15.4,直接sudo apt install qtbase5-dev qtbase5-dev-tools libqt5svg5-dev就行,不需要额外下载任何东西。

3. 核心机制:QTimer 定时器与游戏循环

3.1 用 QTimer 构造固定步长的循环

游戏循环在 Widgets 里简单粗暴:创建一个 QTimer,设置 interval 为 16ms(大约 60FPS),把超时信号连接到自己的updateGame()槽。然后每帧做三件事:更新所有方块位置、判断是否出界并生成新行、调update()触发重绘。

gameTimer = new QTimer(this); gameTimer->setTimerType(Qt::PreciseTimer); gameTimer->setInterval(16); // 约 60FPS connect(gameTimer, &QTimer::timeout, this, &GameWidget::updateGame); gameTimer->start();

这里有一个很容易被忽略但很关键的参数:Qt::PreciseTimer。在 Qt5 里,默认的CoarseTimer会为了省电把定时器合并、漂移,在桌面 Linux 上表现得不明显,但在嵌入式设备上帧率会忽快忽慢。使用PreciseTimer后,Qt 会尽力保证每次 timeout 之间的间隔准确,这是小游戏手感的基础。

updateGame()里我一般会这样写:

void GameWidget::updateGame() { if (state != GameState::Playing) return; boardOffset += 4; // 每次下移 4 像素,节奏偏快 if (boardOffset >= rowHeight) { boardOffset -= rowHeight; scrollRows(); // 所有行向下移动一行 } // 最下面的行已经滚出屏幕,删掉,再从顶部生成一行 if (rows.last().y >= height()) { rows.removeLast(); generateRow(-rowHeight); // 新行出现在屏幕上方 } checkTouchRange(); // 命中判定,见第 4 章 update(); // 请求重绘 }

逻辑说明:boardOffset是行内微调位移,值在 0 到 rowHeight 之间。这样做的目的是让滚动的视觉动画是像素级平滑的,而不是一行一行跳着走。rows这个 QVector 里存的是每一行的数据结构,包含黑块列号和当前 y 坐标。删除和新增都发生在容器两端,不会造成遍历整张表再重建的开销。

rowHeight是个常量,根据屏幕高度和一次显示的行数反推,我习惯取屏幕高度的 1/6。如果想要更快的速度,直接加大boardOffset的增量,不要缩短 timer 的 interval——缩短 interval 会导致 CPU 占用指数上升,而且屏幕刷新率不一定支持。

参数说明:QVector在 Qt5 里默认是写时复制(COW),尾部增删是 O(1);用std::vector也行,但要注意 Qt 容器与信号槽的兼容性。这里rows.removeLast()和generateRow()是针对 QVector 的写法,实际项目中我也不会引入额外的 STL 头文件,避免两套容器混用导致的问题。

3.2 提前生成新行:避免“无块可点”的瞬间

新手常见的翻车现象是玩到第 20 分左右屏幕突然滚空,因为新行生成得太晚了。正确的做法是让可玩区域永远保持 8~10 行,而不是恰好填满屏幕。

void GameWidget::generateRow(int y) { BlockRow row; row.y = y; row.blackCol = QRandomGenerator::global()->bounded(4); // 不让连续两行的黑块在同一列,避免“闪电般连点”的巧合关卡 if (!rows.isEmpty() && rows.first().blackCol == row.blackCol) { row.blackCol = (row.blackCol + 1) % 4; } rows.prepend(row); }

这个bounded(4)是 Qt6 推荐的写法,Qt5 里也是可用的。在 Qt5.10 以下需要改成qrand() % 4,但既然你用的是 Qt5.15,直接写QRandomGenerator就行。连续性限制不是必须的,但实测不加这一行,游戏体验会因为“两行黑块同列”而出现莫名其妙的快速死亡,玩家会觉得手感不稳。

3.3 paintEvent:把数据画出来

绘图是游戏的门面,也是性能瓶颈所在。我的做法是只在paintEvent()里做纯绘制,不碰任何游戏状态。

void GameWidget::paintEvent(QPaintEvent *) { QPainter painter(this); painter.fillRect(rect(), Qt::white); for (const BlockRow &row : rows) { int y = row.y + boardOffset; if (y >= height() || y + rowHeight < 0) continue; // 画三个白块背景 painter.fillRect(0, y, width() / 4, rowHeight - 1, Qt::white); // 画黑块 int x = row.blackCol * (width() / 4); painter.fillRect(x, y, width() / 4, rowHeight - 1, Qt::black); } }

代码逻辑很简单,但有几个细节值得注意。

第一,背景我直接用白色填充,块与块之间留 1 像素的间隙,视觉上才会有网格感。如果不留,纯色场景下黑块连在一起会形成一整坨,玩家分不清边界。第二,绘制前用y >= height() || y + rowHeight < 0做裁剪,这是最简单的性能优化,可以避免 QPainter 在不必要的区域执行填充操作。第三,坐标计算放在循环里,但width() / 4这个除法在窗口尺寸不变的情况下是常量,可以提到循环外。QPainter 的 fillRect 对整块矩形填充的效率很高,不要在 paintEvent 里搞什么 QPixmap 拼贴,纯色填充没有纹理贴图需求,没必要引入缓存。

这里补充一下为什么不自绘却用半个屏幕的黑块做背景图:白块是“安全区域”,纯白背景最容易让玩家把注意力集中在黑块上。反过来,如果背景是花纹或者渐变,视觉噪音会显著增加误触率,这在手机屏幕上尤其致命。

4. 碰撞检测:点的是白块还是黑块

4.1 基于 y 坐标区间的命中判定

鼠标按下时,我们拿到的是全局坐标(mouseX, mouseY)。要判断点没点中黑块,最直接的方法是把mouseY转换成“游戏行索引 + 行内偏移”,再判断该行的黑块列号是否等于mouseX所在的列。任何基于“遍历所有像素”的碰撞检测在这个游戏里都是多余的。

void GameWidget::mousePressEvent(QMouseEvent *ev) { if (state != GameState::Playing) return; int col = ev->pos().x() * 4 / width(); // 0~3 int realY = ev->pos().y() + boardTopOffset; // 若界面顶部有暂停按钮,需要偏移 // 找到 realY 落在哪一行 for (int i = 0; i < rows.size(); ++i) { const BlockRow &r = rows.at(i); int y = r.y + boardOffset; if (realY >= y && realY < y + rowHeight) { if (r.blackCol == col) { gameOver(); } else { score++; updateScoreDisplay(); } break; } } }

ev->pos().x() * 4 / width()这行是关键:把鼠标 x 坐标按窗口宽度等分成四份,得到 0~3 的列号。这里注意先乘后除,避免整除精度把最右边一列给丢掉。如果你用ev->pos().x() / (width() / 4)来算,在窗口宽度不是 4 的整数倍时会算出 4,数组越界。

realY那个偏移量是给界面上方留出标题栏或暂停按钮用的,如果不需要就设成 0。游戏区不从 (0,0) 开始的情况在移植到手机上时很常见,所以这个偏移量建议一开始就设计进去,后面不用返工。

4.2 为什么要用“命中判定”而不是“边框判定”

有些人会把黑块做成一个 QRect,然后rect.contains(ev->pos())来判断。这个写法在原型阶段没问题,但一旦涉及多个行、多个方块,你就得维护 N 个 QRect,对每个 QRect 都要做一次 hitTest。我的做法是只判断当前命中的那一行,其他行全部跳过,因为玩家一次点击只会落在唯一的一行里。

还有一个容易翻车的点是:mousePressEvent 触发时,boardOffset已经在这一帧的 tick 里更新过了吗?Qt 的事件循环里,timer timeout 和 mouse press 是两个独立事件,它们的执行顺序不确定。如果 mouse press 先于 timer,那row.y + boardOffset还是上一帧的位置,视觉上会有一帧的偏差。这个偏差在 60FPS 下只有 4 像素,体感几乎不可感知,所以不用刻意处理。但如果你把帧率降到 30FPS,偏移会变成 8 像素以上,玩家会觉得自己明明点中了却判失败——这个问题在低端设备上很典型,解法是统一在 paintEvent 和 mousePressEvent 里使用同一帧的位移快照,而不是各自算。

我一般会在 GameWidget 里保存一份lastOffset作为“当前帧的位移”,在 updateGame 的末尾更新它,paintEvent 和 mousePressEvent 都只读这个快照。代价是内存多一个 int,换来的是判定与画面严格一致,这属于花小钱消大隐患。

5. 界面分层:暂停按钮、计分显示和触摸优化

5.1 用 QHBoxLayout 把控件叠上去

纯自绘 QWidget 上要放按钮和计分标签,最简单的做法是直接用setLayout铺一层。我用了一个垂直布局:顶部是按钮和分数的水平排列,底部是游戏绘制区。但由于绘制区是重写的 QWidget,不能直接塞进布局里当普通控件用,得用setLayout配合setStretchFactor控制比重。

auto *topBar = new QWidget(this); auto *topLayout = new QHBoxLayout(topBar); scoreLabel = new QLabel(QString::fromUtf8("得分: 0"), topBar); pauseBtn = new QPushButton(QString::fromUtf8("暂停"), topBar); topLayout->addWidget(scoreLabel); topLayout->addWidget(pauseBtn); topLayout->addStretch(); auto *mainLayout = new QVBoxLayout(this); mainLayout->addWidget(topBar); mainLayout->addWidget(gameArea); // gameArea 是真正的自绘控件 mainLayout->setStretch(0, 0); mainLayout->setStretch(1, 1); gameArea->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);

这里我习惯把“游戏区”和“外壳窗口”分开,而不是整个窗口都自绘。原因是按钮的点击事件与游戏区的点击事件需要不同的处理策略,如果全画在一个 QWidget 上,你就得自己手动判断点击坐标落在哪个控件区域。Qt 的父子控件模型本来就能做这件事,何必用手写坐标判断去重新发明轮子。

QHBoxLayout里addStretch()的作用是把按钮和分数左边的空隙顶出去,让它们靠左排列。如果改成居中布局,手机上横屏时按钮会被拉伸得很宽,观感不佳。

5.2 暂停与继续:不要 stop 和 start 定时器之外搞太多逻辑

暂停的核心问题是:暂停那一刻必须把“当前行位置”冻结,而不是让 QTimer 继续跑。做法是让updateGame()第一行检查state != Playing就 return,这样 QTimer 依旧在走,但没有更新逻辑。好处是恢复时不需要重新创建定时器,也不用手动补偿流逝的时间。

void GameWidget::togglePause() { if (state == GameState::Playing) { state = GameState::Paused; gameTimer->stop(); // 可选,也可以不 stop 只 return pauseBtn->setText(QString::fromUtf8("继续")); } else if (state == GameState::Paused) { state = GameState::Playing; gameTimer->start(); pauseBtn->setText(QString::fromUtf8("暂停")); } }

这里有个坑:gameTimer->start()之后,定时器不会立刻发射 timeout,而是会等上一个 interval。所以如果你在暂停期间调用了 start,恢复的那一刻画面会静止最多 16ms 才动起来,这 16ms 几乎感知不到,但如果刻意去测,会发现恢复的节奏慢了一点。想要 perfect,就在 start 前gameTimer->start(0)强制触发一次,再改回 16ms。但这种过度优化在实体机上属于玄学优化,我用过一段时间后还是改回了简单的 stop/start。

手机上另一个常见问题是:点击暂停按钮的误触。因为按钮就在游戏区上方,手指点屏幕时很容易蹭到它。我的解法是把暂停按钮也设计成游戏区的一部分,点击顶部的暂停区域才触发暂停,而不是一个独立的 QPushButton。这样手指在游戏区怎么点都不会误触,因为游戏区从头到尾不响应任何按钮类控件的事件。

5.3 触摸和鼠标事件的兼容

在 Linux 桌面版,我们用 mousePressEvent 就够了。但如果你的目标平台是触摸屏,Qt 默认会把触摸事件合成为鼠标事件,所以同一套 mousePressEvent 逻辑也能工作。唯一的区别是触摸屏的坐标精度和move事件,你需要在event(QEvent *)里手动处理QEvent::TouchBegin来提升响应速度。

bool GameWidget::event(QEvent *e) { if (e->type() == QEvent::TouchBegin || e->type() == QQEvent::TouchUpdate || e->type() == QEvent::TouchEnd) { QTouchEvent *te = static_cast<QTouchEvent *>(e); const QTouchEvent::TouchPoint &pt = te->touchPoints().first(); handleTap(pt.pos().toPoint()); return true; } return QWidget::event(e); }

注意handleTap要复用 mousePressEvent 里的那套命中判定逻辑,不要在触摸和鼠标两个路径里各写一遍。否则调了一次触摸参数发现两边行为不一致,就会掉进调了左边右边挂的坑里。

6. 状态机设计:从开始到结束再到重开

6.1 枚举驱动的状态切换

这个游戏只有 4 个状态:Ready、Playing、Paused、GameOver。用枚举值控制每个函数的行为,是 Qt 小工程里最清晰的方案,也是后面加“倒计时三二一”或者“复活”功能的最省事扩展点。

enum class GameState { Ready, Playing, Paused, GameOver };

每个状态下,各个系统的行为都可以用一张表总结出来:

状态定时器鼠标点击绘制内容切换去向
Ready停开始游戏标题+开始按钮按下后进入 Playing
Playing运行判定黑白块完整对局 + 计分点错黑块进入 GameOver
Paused停继续冻结画面+半透明遮罩点击继续进入 Playing
GameOver停重启最终分数+重开提示点击进入 Ready 或直接 Playing

这张表写清楚后,每个事件响应的if/else分支就固定了:

void GameWidget::handleTap(const QPoint &pos) { switch (state) { case GameState::Ready: startGame(); break; case GameState::Playing: doHitTest(pos); break; case GameState::Paused: togglePause(); break; case GameState::GameOver: restartGame(); break; } }

这种写法最大的好处是:你永远不会因为“漏判状态”而产生某个状态下的悬空 bug。比如,在 GameOver 状态下如果还继续响应点击,玩家就会疯狂加分数——这个 bug 我确实犯过,后来就是靠 switch 全覆盖堵死的。

6.2 加入倒计时以及卡帧的“时间补偿”

如果你想要更完整的体验,可以在游戏开始前加一个“3、2、1”倒计时。最简单的实现是引入一个countdownRemain变量,用同一个 gameTimer 驱动,每过一秒把这个值减一。但注意:如果要支持中途暂停,倒计时的 timer 也得有暂停逻辑,否则玩家暂停 10 分钟再回来,倒计时早就归零了。

我在实际项目里是这么处理的:倒计时不依赖 timer 的 tick 次数,而是记录“游戏逻辑启动的绝对时间”,每次刷新时用QElapsedTimer计算还剩多少毫秒。这个方案可以彻底避开暂停期间的计时漂移问题,以及前面说的“暂停恢复后缺失一帧”的问题。

class GameWidget : public QWidget { QElapsedTimer elapsedTimer; qint64 pausedElapsed = 0; // 已经暂停的累计时间 qint64 countdownMs; // 例如 3000 };

每次updateGame()里,用elapsedTimer.elapsed() - pausedElapsed得出生效时间,用它去减 countdownMs。暂停的时候把pausedElapsed = elapsedTimer.elapsed()存下来,恢复的时候把暂停时间也累加进去。这样一来,无论用户暂停了多久,倒计时的精度都是准确的。

这里的核心思路是“不要让游戏逻辑跟 timer 的触发次数强耦合,而是跟绝对时间强耦合”。这样做还有一个好处:如果你的系统因为负载飙高而卡了 200ms,QTimer 会补偿性地连发 tick,但绝对时间依然准确,玩家感受不到明显的掉速。

7. Android 与嵌入式 Linux 部署:Qt 跨平台的那点事

7.1 从桌面到 Android 的编译选择

项目标题写的是 Linux、Qt、C++,但这类小游戏最容易迁移的平台其实是 Android——毕竟“别踩白块儿”本身就是触屏游戏。Qt5.15 对 Android 的支持已经很成熟,CMake 里需要额外启用android平台的配置。我这里用命令行方式演示,不做 Qt Creator 的图形化过程,因为命令行更适合脚本化和持续集成。

# 假设你已经安装了 Qt 5.15.2 的 Android 套件 ~/Qt/5.15.2/android_arm64_v8a/bin/qmake ../ make -j$(nproc) ~/Qt/5.15.2/android_arm64_v8a/bin/androiddeployqt \ --input ../android-libdont_touch_white.so-deployment-settings.json \ --output ./android-build \ --deployment bundled

这段指令里最容易被忽略的是--input指向的那个 json 文件,它在make完成后由 Qt 自动生成到构建目录里。如果你直接在别处新建一个空 json 传进去,会报dependent '..\..\..\...\include\qtwidgets'之类的头文件查找错误。这个错误的本质是 Android 构建环境里include搜索路径没有按目标平台重新映射,Qt 的 mkspec 找不到交叉编译用的头文件。

解决方式是:确认你的 Qt 是 Android 版本,而不是桌面的 gcc_64 版本。很多人安装了 Qt 后桌面版能用,想编译 Android 就会报这种错,因为 Android 的 mkspec 是android-clang,跟桌面 gcc_64 完全是两套头文件路径。

7.2 触摸事件的参数调优

Android 上跑 Qt Widgets,默认的触摸行为会有两个明显的毛病:

  • 点击后有 300ms 左右的延迟(长按弹出上下文菜单的机制残留);
  • 滑动时游戏区会被系统当成滚动,产生惯性。

第一个问题要用setAttribute(Qt::WA_AcceptTouchEvents)加上禁用上下文菜单事件来解。第二个问题比较麻烦,Qt Widgets 没有像 QML 那样内置flickable,所以需要自己拦截所有触摸事件,阻止系统默认行为。

gameArea->setAttribute(Qt::WA_AcceptTouchEvents); bool GameWidget::event(QEvent *e) { switch (e->type()) { case QEvent::TouchBegin: case QEvent::TouchUpdate: case QEvent::TouchEnd: return true; // 不再传播给父类,防止系统手势触发 default: break; } return QWidget::event(e); }

这段代码在桌面 Linux 上运行没有任何影响,因为触摸事件本身不会产生;在 Android 上则可以避免滚动条和误触装死问题。

7.3 在嵌入式 Linux 设备上的注意事项

如果你最终要把它跑在 RK3288、全志 H3 或者树莓派这样的板子上,有几个性能相关的心得分享:

  • 不要开 Qt 的 OpenGL 渲染,除非你的板子驱动已经确认稳定。QT_QUICK_BACKEND=software在 Widgets 上并不适用,Widgets 的软件渲染用-platform linuxfb即可;
  • 关闭 Qt 的字体平滑、透明效果,能明显降低 CPU 负载;
  • 16ms 的 QTimer 在嵌入式平台上不一定精确,因为内核调度抖动较大。如果你的板子 CPU 比较弱,可以把逻辑帧率降到 30FPS(33ms),然后用双倍间距的行生成来保持滚动速度一致;
  • 实测linuxfb平台下 QPainter 的 fillRect 性能尚可,但每个像素的填充是纯 CPU 操作。屏幕分辨率超过 1080P 时建议把游戏区 QWidget 的大小限制在 720P 左右,然后 scale 上去,否则发热和功耗会非常难看。

8. 存档与最高分:用 QSettings 还是配置文件

8.1 为什么用 QSettings

最高分和音效偏好这类配置,Qt 最省事的持久化手段就是 QSettings。在 Linux 上它默认写入~/.config/目录下的一个 ini 文件,在 Android 上则由 Qt 封装成 SharedPreferences 的形式,无需你关心路径问题。

#include <QSettings> void GameWidget::saveHighScore(int score) { QSettings settings(QString::fromUtf8("mycompany"), QString::fromUtf8("donttouchwhite")); int old = settings.value(QString::fromUtf8("highscore"), 0).toInt(); if (score > old) { settings.setValue(QString::fromUtf8("highscore"), score); } }

QSettings的构造参数是“组织名”和“应用名”。在 Linux 上对应的路径是~/.config/mycompany/donttouchwhite.conf,在 Windows 上是注册表,在 Android 上是私有存储。跨平台自动切换,这是它最大的价值。

读取的时候,我习惯用一个独立的GameConfig类去包一层,防止业务代码里到处裸用 QSettings。所有键名都定义成常量,改配置项的时候只动一处。

8.2 单例与全局状态

最高分在游戏运行期间需要被多处读取,包括顶部计分栏、GameOver 画面和开始界面。如果每次想要拿最高分都去 new 一个 QSettings,代码会很难看。通用的做法是做一个单例GameConfig::instance()。

class GameConfig { public: static GameConfig& instance() { static GameConfig inst; return inst; } int highScore() const { return mHighScore; } void setHighScore(int s); private: GameConfig() { load(); } void load(); int mHighScore; };

C++11 里的函数局部静态变量可以保证线程安全初始化,所以这种单例写法在 Qt 里是安全的。GameWidget里的分数变化时,可以GameConfig::instance().setHighScore(score)做一次“尝试写入”,而不用关心写没写成功——反正没有更高分就不写。

8.3 二进制还是文本格式

对于这种小游戏,纯文本 QSettings 是最好的。如果你追求极致性能想用二进制文件,那就得自己处理端序、对齐和校验,收益却不高。最高分这种整数,写在 ini 文件里还能从外部直接改,方便调试。

日志类的数据才需要二进制格式,游戏存档最高分完全没有必要去动用二进制序列化——这是很多年轻人容易想多的点。你做一个 QJsonDocument 存进去,在 Qt 工程里反而比 QSettings 麻烦,因为你要手动管理格式解析错误、缺键默认值等边界情况。

9. Qt5 迁移 Qt6 遇到的代码差异:我的避坑向笔记

9.1 现象与排查:HIGHDPI与模块拆分

Qt6 发布后,很多 Linux 发行版默认源从 Qt5.15 切换到了 Qt6。如果你在 Ubuntu 22.04 上 apt 安装的 qtbase5-dev,但系统中同时存在 Qt6,CMake 的 find_package 可能会找到 Qt6 的Qt6::Widgets。表现是:链接失败,报cannot find -lQt6Widgets或undefined reference to vtable for QWidget。

原因很简单,Qt6 默认的 C++17 支持更激进,而且 Qt Widgets 的 CMake 宏名与 Qt5 不同。解决方法是显式指定版本:

find_package(Qt5 5.15 REQUIRED COMPONENTS Widgets)

如果是 Qt6,则要把Qt5::Widgets改成Qt6::Widgets,同时把QString::fromUtf8改成QString::fromLocal8Bit(虽然这个函数在 Qt6 里还保留着 deprecated 状态)。

9.2 现象与排查:QRandomGenerator的过时问题

Qt5.15 里QRandomGenerator::global()->bounded(4)可用,Qt6 里也没问题。但在 Qt 5.10 及以下,QRandomGenerator尚未引入,需要qrand()。如果你需要在两种版本下都兼容,可以写一个版本判断宏,或者直接自己实现一个简单的随机数。

int randomColumn() { #if QT_VERSION >= QT_VERSION_CHECK(5, 10, 0) return QRandomGenerator::global()->bounded(4); #else return qrand() % 4; #endif }

这个宏在跨版本编译时很有用,但写多了会让代码变得很丑。我个人的习惯是:如果团队统一用 Qt5.15 或 Qt6,就只保留新 API;只有发布包要面向老系统时才加兼容宏。

9.3 现象与排查:-1: error: dependent '..\..\..\..\qt\5.15.2\msvc2019_64\include\qtwidgets'这类头文件路径错误

这是 Windows 上 Qt 和 VS 搭配时最容易遇到的坑之一。报错里的那个长路径指向了一个 Windows 下的 Qt 安装目录,但在 Linux 项目里你绝对见不到这个路径——它只会在你把 Windows 的 qmake 工程拿到 Linux 上重新构建时出现。

原因是.pro文件里写了INCLUDEPATH += $$[QT_INSTALL_HEADERS],而这个值在 Windows 的 qmake 环境里正确地指向 Qt 安装目录,但在 Linux 上 qmake 解析的是另一个套件。解决方法是不要手动去改路径,直接用 Qt 提供的 CMake 变量:

include_directories(${Qt5Widgets_INCLUDE_DIRS})

或者干脆不写 include,因为target_link_libraries(... Qt5::Widgets)已经隐含了 include 路径。如果你用 qmake,更简单的做法是把.pro里的QT += widgets保持原样,用$$[QT_INSTALL_HEADERS]动态获取,千万不要手写一个硬编码路径。

这个报错在搜索引擎里经常出现,很多人都是因为它而卡在“从 Windows 移植到 Linux”这一步,所以我单独拿出来讲一次。

10. 回归测试与性能上限:怎么验证你的游戏“真的能玩”

10.1 手工回归清单

游戏类项目不像 Web 后端有完善的断言框架,最可靠的验证方式是列一张手工回归清单,每次改完核心代码都跑一遍:

  • 从 Ready 状态点击开始,倒计时正常走完;
  • 点白块得分正常增加,计分标签刷新;
  • 点黑块立即 GameOver,且不会继续响应点击;
  • 暂停后画面冻结,恢复后得分不重置;
  • 连续玩 20 分钟,内存不增长(用top观察,RSS 应稳定);
  • 在高分超过旧纪录时,退出并重启程序,最高分被正确恢复;
  • 窗口 resize 后四列宽度保持均匀,游戏区域不产生黑边或分割线错位。

这套清单在每次改动后花 3 分钟就能跑完,但能拦截 90% 的回归 bug。我不建议给这个项目写自动化 UI 测试,因为 Qt Test 的鼠标模拟在这个场景下反而会带来“事件顺序不可控”的额外问题。

10.2 用命令行参数跑压力测试

你可以给程序加一个隐藏的--autoplay参数,自动模拟玩家点击所有白块,从而验证游戏能跑多久不崩。

int main(int argc, char *argv[]) { QApplication app(argc, argv); bool autoplay = app.arguments().contains(QStringLiteral("--autoplay")); GameWidget w; if (autoplay) { w.enableAutoPlay(); } w.resize(400, 800); w.show(); return app.exec(); }

enableAutoPlay()里会启动一个单独的 QTimer,每隔 50ms 用QCoreApplication::postEvent构造一个鼠标点击事件,发到gameArea。这样既验证了事件循环不会阻塞,又能观察长时间运行下的内存和 CPU 曲线。这一步在生产环境里很实用,尤其是在嵌入式板子上刷机时,能快速判断 Qt 的渲染是否跟得上。

10.3 用 Qt 自带的QElapsedTimer检查帧耗时

与其用top去猜,不如在代码里打一个耗时统计点:

void GameWidget::updateGame() { QElapsedTimer frameTimer; frameTimer.start(); // ...原有逻辑... qreal cost = frameTimer.nsecsElapsed() / 1000000.0; // 单位 ms if (cost > 16.0) { qWarning() << "frame over 16ms:" << cost; } }

这个警告出现在调试终端里,如果连续几十帧都超时,说明你的绘图逻辑或碰撞检测有性能问题,需要回到 paintEvent 去找循环开销。正常情况下纯填充 400x800 的画面不会超过 5ms,超过 10ms 就该检查是不是误用了QLabel叠加绘制。

11. 进阶优化:无缝重开、动画过渡和触觉反馈

11.1 无缝重开:回归状态而不是重建窗口

很多人实现“再来一局”会直接delete旧窗口再 new 一个。这个方案能跑,但会有短暂的窗口闪烁和焦点重置问题。更顺滑的方法是设计restartGame(),把 rows 清空、score 归零、boardOffset 置零,再重新生成初始行。

void GameWidget::restartGame() { rows.clear(); boardOffset = 0; score = 0; updateScoreDisplay(); generateRow(-rowHeight * 2); generateRow(-rowHeight * 3); generateRow(-rowHeight * 4); state = GameState::Playing; gameTimer->start(); }

初始化生成 3~4 行而不是一行,是为了让画面一开始就有“即将滚动”的压迫感,而不是前两秒空荡荡的。这个细节第一次做的时候很容易忽略,后来玩了几局后我自己都感觉“开局太空”,才补上的。

无缝重开的关键是不要动 QApplication 和 GameWidget 实例,只重置内部状态。GameOver 画面上的“重开”按钮响应后调用这个方法,视觉上是瞬间进入新游戏,没有任何窗口重建的卡顿。

11.2 给黑块加一个“闪现”动画

纯色填充虽然高效,但视觉上确实单调。我用的一个低成本动画方案是:在 GameOver 时让所有黑块变成半透明的红色闪烁一下,再显示结算面板。实现方式不复杂,在 GameWidget 里增加一个flashAlpha变量,GameOver 时用另一个单次 QTimer 从 1.0 递减到 0.3,每帧触发 update()。

QTimer::singleShot(300, this, [this]() { flashAlpha = 0.3; update(); });

这个动画在 paintEvent 里作用于黑块颜色:

if (state == GameState::GameOver) { painter.fillRect(x, y, width() / 4, rowHeight - 1, QColor(255, 0, 0, 255 * flashAlpha)); } else { painter.fillRect(x, y, width() / 4, rowHeight - 1, Qt::black); }

QColor带一个 alpha 通道后需要painter支持透明融合,默认的 fillRect 会自动用 source-over 混合,效果是红色在半透明的黑块上显出暗红色闪动,视觉反馈清晰又不刺眼。注意update()触发的重绘效率,别在闪动期间做其他耗时操作,否则动画会掉帧、看起来像卡死。

11.3 触觉反馈:别在 Qt Widgets 里自己做,交给系统层

Android 上做震动反馈,不要在 C++ 层自己写震动线程——那是把事情搞复杂了。用QtAndroid模块或者通过 JNI 调用系统的 Vibrator 服务,最稳。Linux 桌面上这步直接跳过,因为大多数设备没有震动马达。

#ifdef Q_OS_ANDROID #include < QAndroidJniObject > // 触发 50ms 振动 QAndroidJniObject::callStaticMethod<jboolean>( "org/qtproject/qt5/android/QtNative", "vibrate", "(I)V", 50); #endif

这是 Qt5.15 上常见写法,但新版 Qt6 已经把这个模块移到了QtCore之外。我建议只在 Android 分支里做,桌面和嵌入式 Linux 干脆不编译这块代码。加上这个特效后,玩家点击黑块时的“失落感”会明显增强,游戏本身也会显得完整不少。

11.4 结尾一点我的习惯

每次改完代码,我先跑 autoplay 模式让它自己玩十分钟,再手动玩三局,确认手感没有偏离。之前我嫌麻烦跳过这一步,结果把判定阈值改出了偏差,自己玩着“还挺顺”,真机上一测发现总是误触——所以后来这个习惯就固定下来了。这类 Qt 小游戏项目,代码本身不难,难的是把事件顺序、绘制边界和状态切换这些细节磨严,做到这一步,你离一个真正可交付的嵌入式触摸小游戏已经不远了。希望这篇笔记里的踩坑记录能帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询