ARTICLE DETAIL

资讯详情

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

基于Qt/C++复刻“别踩白块儿”的Linux实战笔记

基于Qt/C++复刻“别踩白块儿”的Linux实战笔记 简介基于 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 和 C17 复刻了“别踩白块儿”从最简单的单文件 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 # 高分持久化用 QSettingsmain.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_castQTouchEvent *(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停开始游戏标题开始按钮按下后进入 PlayingPlaying运行判定黑白块完整对局 计分点错黑块进入 GameOverPaused停继续冻结画面半透明遮罩点击继续进入 PlayingGameOver停重启最终分数重开提示点击进入 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 的触发次数强耦合而是跟绝对时间强耦合”。这样做还有一个好处如果你的系统因为负载飙高而卡了 200msQTimer 会补偿性地连发 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_BACKENDsoftware在 Widgets 上并不适用Widgets 的软件渲染用-platform linuxfb即可关闭 Qt 的字体平滑、透明效果能明显降低 CPU 负载16ms 的 QTimer 在嵌入式平台上不一定精确因为内核调度抖动较大。如果你的板子 CPU 比较弱可以把逻辑帧率降到 30FPS33ms然后用双倍间距的行生成来保持滚动速度一致实测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; };C11 里的函数局部静态变量可以保证线程安全初始化所以这种单例写法在 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但系统中同时存在 Qt6CMake 的 find_package 可能会找到 Qt6 的Qt6::Widgets。表现是链接失败报cannot find -lQt6Widgets或undefined reference to vtable for QWidget。原因很简单Qt6 默认的 C17 支持更激进而且 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::callStaticMethodjboolean( org/qtproject/qt5/android/QtNative, vibrate, (I)V, 50); #endif这是 Qt5.15 上常见写法但新版 Qt6 已经把这个模块移到了QtCore之外。我建议只在 Android 分支里做桌面和嵌入式 Linux 干脆不编译这块代码。加上这个特效后玩家点击黑块时的“失落感”会明显增强游戏本身也会显得完整不少。11.4 结尾一点我的习惯每次改完代码我先跑 autoplay 模式让它自己玩十分钟再手动玩三局确认手感没有偏离。之前我嫌麻烦跳过这一步结果把判定阈值改出了偏差自己玩着“还挺顺”真机上一测发现总是误触——所以后来这个习惯就固定下来了。这类 Qt 小游戏项目代码本身不难难的是把事件顺序、绘制边界和状态切换这些细节磨严做到这一步你离一个真正可交付的嵌入式触摸小游戏已经不远了。希望这篇笔记里的踩坑记录能帮到你。本文还有配套的精品资源点击获取
返回列表