ARTICLE DETAIL

资讯详情

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

在Linux上用Qt打造别踩白块儿:qmake、事件循环与坐标判定实战

在Linux上用Qt打造别踩白块儿:qmake、事件循环与坐标判定实战 简介基于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()为什么直接用 QRandomGeneratorC 初学者习惯用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 的随机接口C11 的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 c11这一行它让编译时启用 C11 标准QRandomGenerator和QVector的现代接口都需要它。如果系统里同时装了 Qt4 和 Qt5pro 文件里的widgets模块名也是区别两者的重要标志Qt4 里没有这个模块。QT widgets TARGET whiteblock TEMPLATE app CONFIG c11 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 编译的但你现在链接的库或头文件是另一个版本。解决思路不是去搜“哪个版本匹配”而是先确认自己到底在用哪个 qmakeqmake -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黑块的位置用一个QVectorint存列号绘制时根据列号和行号画矩形。这样状态数据结构极简判定直接做坐标除法不用维护 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(); QVectorint 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_PLATFORMlinuxfb但系统里根本没装对应插件包。解决桌面环境下强制指定 xcb 插件运行或直接不设置环境变量让 Qt 自动探测。嵌入式环境则安装对应平台插件包并检查插件目录与编译时是否一致。QT_QPA_PLATFORMxcb ./whiteblock # 桌面 X11 环境 QT_QPA_PLATFORMoffscreen ./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_castQTouchEvent *(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_PLATFORMlinuxfb。无头服务器上想自动截图验证 UI用offscreen平台就能跑出完整界面。每次改完参数后我都会在真机上连续玩三分钟记录漏踩次数直到稳定在每分钟 2 次以下才算通过。这个小项目虽然只有几百行代码但它把事件循环、坐标计算、随机生成、绘图和平台差异全部练了一遍。希望这份经验能帮你在 Linux 上少走几个我走过的弯路。本文还有配套的精品资源点击获取
返回列表