ARTICLE DETAIL

资讯详情

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

Qt C++ 事件循环与定时器:手写别踩白块儿游戏的关键技术

Qt C++ 事件循环与定时器:手写别踩白块儿游戏的关键技术 简介基于Linux、Qt和C实现的“别踩白块儿”小游戏完整工程面向有一定C基础、希望掌握Qt游戏界面开发与逻辑设计的读者。压缩包共52个文件约736KB其中包含6个cpp源文件、5个头文件、2个ui界面文件以及34个png界面素材pro工程文件可直接用于Qt项目加载适合作为课程设计或Qt入门练手项目。实现了4×4网格中随机生成单个黑块采用时间种子配合qrand保证随机性通过QTimer定时器每100ms刷新倒计时初始时间30秒利用工厂模式生成块对象并用queue容器管理队列点击黑块后删除队头并整体下移同时支持得分与历史最高分记录。代码结构清晰包含游戏界面、控制逻辑与资源文件分离可帮助读者理解Qt事件循环、定时器机制、常用容器及设计模式的实际应用已有1182人学习下载是巩固C/Qt编程能力的实用参考。1. 用 Linux Qt C 写“别踩白块儿”难的不是画方块把“别踩白块儿”用 Linux Qt C 做出来很多教程只让你画四列黑白方块真正动手才发现难点根本不在画面上。QTimer 怎么驱动行下降、键盘事件为什么时灵时不灵、判定超前还是滞后这些才是让游戏“能玩”和“不能玩”的分界线。这篇笔记用 Qt Widgets 写一个最小但完整的实现从 Ubuntu 环境准备、CMake 工程骨架、绘制与随机列生成一路走到命中判定和状态机复位并把几个我踩过的坑按现象列出来。适合第一份 Qt 小游戏作业也适合想接手嵌入式 Linux 项目前先用 Qt 练手的工程师。2. 环境与骨架先行Ubuntu 下 Qt 5.15 CMake C17 的最小项目怎么搭2.1 用 apt 装好三件套qtbase5-dev、cmake 和 g然后验证版本我一般不建议在小游戏项目上直接下载 Qt 在线安装包Ubuntu 的 apt 仓库里已经带了完整的 Qt 5.15 开发套件装完就是系统级路径qmake、cmake 和 g 各归各位后续交叉编译也好排查。嵌入式 Linux 项目里常见的做法是先确认目标板上用的 Qt 是哪个小版本再在开发机上装同版本的 dev 包否则后面拷到板子上运行时常会遇到 Qt 库版本不匹配。这里先以 x86 Ubuntu 为例sudo apt update sudo apt install qtbase5-dev qtbase5-dev-tools cmake g -y qmake --version cmake --version g --versionqtbase5-dev 提供的是 Qt5Widgets 的头文件、libQt5Widgets.so 和默认的 mkspecqtbase5-dev-tools 里才是 moc、uic、rcc 这些命令行工具。很多教程只装了 qtbase5-dev结果编译时报错说找不到 Qt5WidgetsConfig.cmake就是因为 dev-tools 和 dev 包没有成对安装。三条验证命令里qmake --version 看的是 Qt 版本路径cmake --version 看构建系统版本g --version 看编译器是否就绪如果 qmake 输出里带msvc2019或clang字样说明 PATH 里混进了别的 Qt这种环境问题越早暴露越好。另外补一句用 VSCode 改 C 代码是完全可行的只需要配置好 CMake 插件和 C/C 插件的 include 路径Qt 的头文件目录一般在/usr/include/x86_64-linux-gnu/qt5。我这里列一张常用工具表moc 处理 Q_OBJECT 元对象、rcc 编资源文件、uic 编 .ui 界面三者职责别搞混工具输入输出本项目的用途moc含 Q_OBJECT 的头文件moc_xxx.cpp处理信号槽元对象uic.ui XML 文件ui_xxx.h本项目不用纯代码生成界面rcc.qrc 资源清单qrc_xxx.cpp后续加音效/图标时再用qmake.pro 项目文件Makefile备选构建方式本文用 CMake2.2 CMakeLists 怎么写全文件只有三件关键配置这个项目的依赖面非常窄只用到 Qt5::Widgets 一个组件所以 CMake 配置可以压到十几行。用 CMake 而不是 qmake 的原因很简单后续如果你想把游戏逻辑抽成动态库或者接入 SQLite、protobuf 这类第三方库CMake 的find_package和target_link_libraries扩展起来更顺手而且 Qt 官方新版本也把 CMake 作为一等构建系统在推进。cmake_minimum_required(VERSION 3.16) project(NoWhiteBlock CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_AUTOMOC ON) find_package(Qt5 COMPONENTS Widgets REQUIRED) add_executable(nowhiteblock src/main.cpp src/gamewidget.h src/gamewidget.cpp ) target_link_libraries(nowhiteblock PRIVATE Qt5::Widgets)CMAKE_AUTOMOC ON这行是关键没有它包含 Q_OBJECT 的头文件不会自动跑 moc链接阶段会报出一堆 undefined reference。find_package(Qt5 COMPONENTS Widgets REQUIRED)里的 COMPONENTS 不是摆设它告诉 CMake 只需要 Widgets 模块这样就不会把 QML、Network、Sql 这些用不到的库全拉进来编译时间能短不少。PRIVATE链接表示这些头文件和库只对当前目标可见如果工程后面拆成多个子目标这种隔离能避免传递依赖带来的头文件污染。入口文件没什么好说的三行启动逻辑加一个固定尺寸的窗口#include QApplication #include gamewidget.h int main(int argc, char *argv[]) { QApplication app(argc, argv); GameWidget w; w.setWindowTitle(No White Block); w.resize(360, 640); w.show(); return app.exec(); }app.exec()一旦进入只要不退出事件循环QTimer 和键盘事件都由 Qt 去调度分发了游戏里的“循环”是靠 Qt 的事件循环驱动的而不是自己在 main 里写 while。2.3 用 QWidget 当游戏画布为什么不用 QMainWindow 也不用 QML“别踩白块儿”的本体就是一块 4 列的画布加上分数和状态提示整个界面没有菜单栏、工具栏、状态栏所以直接让 QWidget 作为顶层窗口最省事。QMainWindow 适合今后要加工具栏和菜单栏的编辑器类应用那套 centralWidget Docking 布局在小游戏里是负资产每按一次键都要穿过 QMainWindow 再进到绘图控件焦点和事件链路长了一环排查问题反而麻烦。QML 当然也能写触摸背景的滑动效果更顺滑但如果你是想练 C 逻辑和 Qt Widgets 底层机制控件的 paintEvent、键事件、定时器这三样东西绕不开。我一般会把游戏逻辑和绘制放在同一个类里不拆 View 和 Controller免得为了“架构”来回传信号。还有一点不用 Qt Designer也不用 Designer 生成的 .ui 文件。纯代码创建 QWidget改动都留在 .cpp 里git 冲突概率低编译生成物也干净对单人小项目更可控。之后要加按钮加菜单在构造函数里一句一句写反而是最稳妥的路子。3. 让游戏循环转起来QTimer 驱动行下降A/S/D/F 四键映射四种列值3.1 QTimer 是游戏心脏interval 参数和难度曲线一个小游戏的常规实现错误是用QThread::sleep来控制节奏这一睡会把整个 GUI 事件循环冻结画面卡住、按键失灵除非你把游戏逻辑放到独立线程里。Qt 里最朴素的方案是用QTimer定时发timeout()信号驱动行数据整体下移一格。这里要理解一个参数interval是两次 timeout 之间的目标间隔单位毫秒它不保证精确只保证“至少间隔这么久”Linux 桌面上的实际误差在几毫秒到几十毫秒之间波动所以别把判定精度压在定时器身上。构造函数里我这样初始化GameWidget::GameWidget(QWidget *parent) : QWidget(parent) , m_state(STATE_READY) , m_score(0) , m_intervalMs(500) { setFocusPolicy(Qt::StrongFocus); m_timer.setTimerType(Qt::PreciseTimer); connect(m_timer, QTimer::timeout, this, GameWidget::advanceRows); reset(); }setTimerType(Qt::PreciseTimer)在 Linux 下会尽量靠近设定间隔但代价是可能更频繁地唤醒 CPU等到速度降到 80ms 一行以下的时候把它改回Qt::CoarseTimer反而能减少调度抖动。connect 把 timeout 信号接到 advanceRows这个函数每触发一次就代表游戏推进一步。难度曲线我用的是线性阶梯初始 500ms 一行每消除 10 个块interval 减 10ms下限是 80ms。窗口高 640、行数 20 时行高 32px速度可以换算成每秒多少像素interval(ms/行)行高32px时的速度手感表现50064 px/s新手熟悉按键200160 px/s正常速度100320 px/s需要预判80400 px/s接近手速极限为什么下限是 80 而不是 10因为 QTimer 的精度和事件循环的响应速度共同决定了人眼到按键的反馈链路再快就没意义了反而变成靠运气连按。这个参数阶梯放在 reset 里统一赋值保证每次重开都是一条全新的难度曲线。3.2 keyPressEvent焦点策略是第一个暗坑QWidget 默认的focusPolicy是Qt::NoFocus也就是说窗口刚 show 出来时游戏控件根本不接收键盘焦点按 A/S/D/F 事件全丢给父窗口了。这也是“按键没反应”最常见的第一个原因。两个动作配合解决构造函数里setFocusPolicy(Qt::StrongFocus)并在showEvent里调一次setFocus()确保进入游戏时焦点落在画布控件上。如果之后你在界面上加了按钮或输入框点击它们会把焦点抢走那时候就要考虑用eventFilter在窗口层统一拦截按键不能继续依赖子控件的 keyPressEvent。按键到列值的映射在这里我用 A/S/D/F 四键是因为它们在键盘上横向排开左手四根手指刚好一列一个void GameWidget::keyPressEvent(QKeyEvent *event) { if (event-isAutoRepeat()) { return; } if (m_state ! STATE_RUNNING) { return; } int column -1; switch (event-key()) { case Qt::Key_A: column 0; break; case Qt::Key_S: column 1; break; case Qt::Key_D: column 2; break; case Qt::Key_F: column 3; break; default: return; } const int targetRow findLowestUnclearedRow(); if (targetRow 0) { return; } if (m_columns.at(targetRow) column) { clearRow(targetRow); } else { gameOver(); } }event-isAutoRepeat()这行很多人会漏。按住 A 不放时操作系统会不断产生重复按键事件没有这行判断一次按压就能把一列上连续堆叠的好几块全部消掉游戏节奏直接崩坏。按键映射完成后的判定逻辑是下一小节的核心先记住一个原则所有命中判定都发生在按键事件里判定目标是“当前最靠底部、还没被消掉的那一行”。3.3 findLowestUnclearedRow命中判定必须先找“待命行”再比对列“别踩白块儿”和“打地鼠”不一样它不允许你跳行消块你只能按正在逼近底部判定线的那个黑块。如果允许任意黑块被消除玩家可以先消上面的、漏掉下面的游戏就失去规则意义了。数据上用QVectorint m_columns保存整张棋盘下标是行号元素值是该行黑块所在的列号-1 表示已消除。行 0 在屏幕顶部尾部元素在底部所以找“当前要处理的行”是从尾部往前扫int GameWidget::findLowestUnclearedRow() const { for (int i m_columns.size() - 1; i 0; --i) { if (m_columns.at(i) ! -1) { return i; } } return -1; }每 tick 推进的逻辑是advanceRows它要同时做三件事头部插入一行新的随机列、底部弹出旧行、检查弹出的旧行是不是没来得及消除void GameWidget::advanceRows() { m_columns.push_front(spawnColumn()); if (m_columns.size() m_rowCount) { const int removed m_columns.takeLast(); if (removed ! -1) { gameOver(); return; } } update(); }takeLast()弹出的那一行如果还不是 -1说明它带着黑块滚出了屏幕这在规则里算漏判直接判负。很多初版实现只做 push_front 不做 takeLast 校验表现就是黑块出界了游戏还在跑这是比画面粗糙更严重的逻辑 bug。再说一句性能QVector::push_front在 Qt 5 里是 O(n) 的整体搬移但行数只有 20无伤大雅如果未来把棋盘扩到上百行建议改成固定数组加头尾索引的环形缓冲。命中判定、漏判和定时器三者联动游戏循环的核心就闭环了。4. 画布与生成器QPainter 坐标换算、均匀随机列和可复位的状态机4.1 paintEvent 里按比例换算坐标不写死任何像素绘制没什么高深的关键是坐标系别写死。窗口可以被用户拉大缩小如果你在代码里写死“每块宽 90px”窗口变成 500px 宽时四列就会左边挤右边空。正确做法是每次 paintEvent 直接用当前窗口尺寸换算void GameWidget::paintEvent(QPaintEvent *event) { Q_UNUSED(event); QPainter painter(this); painter.fillRect(rect(), Qt::white); const qreal rowHeight height() / qreal(m_rowCount); const qreal colWidth width() / 4.0; for (int i 0; i m_columns.size(); i) { const int col m_columns.at(i); if (col 0) { continue; } const QRectF blockRect(col * colWidth, i * rowHeight, colWidth, rowHeight); painter.fillRect(blockRect, Qt::black); } }height() / qreal(m_rowCount)这行的关键在qreal。如果两个 int 相除C 会先做整除再转浮点窗口高度一旦不能被行数整除底部会留下一截没画的空隙。用浮点先除后乘每行之间最多有 0.5 像素的偏差视觉上能接受。QRectF允许半像素坐标配合浮点行高时不会出现相邻黑块之间漏一条白线的情况。QPainter 在这种场景下没有技巧只有一条血泪经验painter 对象必须在作用域内正常析构如果你在 paintEvent 里手动 delete 或提前painter.end()再继续画Qt 在部分 Linux 图形平台下会直接崩表现就是“qt崩溃”的典型现场之一。不要在绘图代码里 new 任何堆对象QPainter 每次重绘会频繁调用堆分配会成为隐藏性能黑洞。还有一个嵌入式 Linux 上很实用的调试技巧没有显示器时可以用 offscreen 平台跑完整绘制逻辑做冒烟测试QT_QPA_PLATFORMoffscreen ./nowhiteblock4.2 spawnColumn均匀随机列避免连续同列和单列扎堆每行黑块的列号是个 0~3 的均匀随机数但直接随机会出两个影响手感的问题。第一是连续两行黑块在同一列玩家只需要连按同一个键毫无趣味第二是短时间多行集中在某一列另外三列形同虚设。我的随机生成策略是“排除上一列 尽量挪位”int GameWidget::spawnColumn() { static int lastColumn -1; int candidate QRandomGenerator::global()-bounded(4); if (candidate lastColumn) { candidate (candidate QRandomGenerator::global()-bounded(3) 1) % 4; } lastColumn candidate; return candidate; }bounded(4)返回 0~3 的均匀随机数QRandomGenerator::global()是 Qt 提供的全局线程安全生成器不需要自己管理种子。当随机结果和上一列撞车时不要重新随机直接把列号平移 1~3 格这样既保证不是同列又不会产生明显的列号规律。严格说这样会让分布略偏均匀但对游戏体验是好事——真实随机反而会出现连排同列那才是玩家骂街的来源。这个函数的粒度是“行”不是“整列序列”。有些人会先把整张 20 行棋盘预生成好再滚动我建议不要预生成全部头插一行淘汰一行的动态方案更贴近无限下落的感觉并且能随时在 gameOver 后干净地重新铺盘。4.3 reset 状态机和重开后发分数、速度、棋盘三样必须一起归位游戏结束后的复位最容易犯的错是只清棋盘不清速度和分数。reset 方法长这样void GameWidget::reset() { m_columns.clear(); for (int i 0; i m_rowCount; i) { m_columns.append(spawnColumn()); } m_score 0; m_intervalMs START_INTERVAL_MS; m_timer.start(m_intervalMs); m_state STATE_RUNNING; update(); }先铺满 20 行玩家一开局就有完整的棋盘可看而不是等 20 个 tick 从空屏幕慢慢长出来。m_intervalMs 必须重新赋回首次速度否则上一局已经加速到 80ms 的定时器会直接继承给下一局新手第一秒就面对极限速度直接崩盘。分数归零、定时器用新间隔重启这三行缺一不可。gameOver 时则反过来先停表再画最后画面void GameWidget::gameOver() { m_state STATE_OVER; m_timer.stop(); update(); }stop 之后 paintEvent 里用m_state画一行 Game Over 文字并按 R 键调 reset。这里有个细节不要在 paintEvent 里根据 m_state 决定是否增加一个绘制分支后忘记在 reset 里把状态切回 STATE_RUNNING那会导致重新开始后画面正常但按键全部被拦截进不了判定。这种状态机分支我习惯在写完一版后逐个状态跑一遍“按键、漏块、重开”三个动作比在代码里反复读 else 分支快得多。5. 避坑按键失灵、画面变花、重开不归零6 个 Qt 小游戏高频问题5.1 按键没反应焦点策略和焦点抢占现象程序跑起来窗口显示正常狂按 A/S/D/F 一点反应都没有qDebug 打出来的日志一条都不走。原因QWidget 的默认 focusPolicy 是 NoFocus键盘事件根本进不了 keyPressEvent或者窗口里后续加了按钮点击按钮后焦点被抢走。解决构造里写setFocusPolicy(Qt::StrongFocus)窗口 show 前调用setFocus()界面上添加任何可点击控件后在主窗口实现eventFilter统一拦截按键不再依赖子控件的键盘事件。我自己的习惯是键盘输入优先于鼠标输入界面尽量不放按钮宁可让他们按 R 重开。5.2 一行黑块被多次消除连击计数错乱现象清除一个黑块后同一个位置被计了两次分或者分数一次加 2。原因findLowestUnclearedRow返回的是-1 时没有提前 return导致同一行被重复判断。解决在 keyPressEvent 里完成命中判定后立刻把该行标记为 -1clearRow 函数里同时对同一行做幂等保护。调试时给 findLowestUnclearedRow 的调用处加qDebug() targetRow一下就能看到二次命中的日志。5.3 黑块滚出屏幕底部游戏不判输现象某一列的黑块从底部消失了但游戏还在运行后面继续计分。原因游戏循环只做了 push_front 生成新行没有检查 takeLast 弹出的旧行是否已经消除。解决在 advanceRows 里取出的 removed 值不等于 -1 时直接 gameOver。这一行是你整个游戏循环里最重要的“判漏”保护没有它就等于白块黑块都能出界游戏规则直接就散了。5.4 窗口拉伸后点击错位、绘制串位现象默认 360x640 窗口下一切正常一旦把窗口拉宽一截黑块和点击位置对不上。原因绘制用了width() / 4.0换算列宽但某个地方还残留写死的固定列宽常量或者把第 0 列的左边限写死成了 0。解决全项目搜索90这个像素宽度列宽一律改成在 paintEvent 里实时计算不要让坐标跨越多个函数传递。更稳妥的做法是窗口固定尺寸设置setFixedSize(360, 640)或setMinimumSize(360, 640)从源头消灭坐标系漂移。5.5 重开游戏之后速度和分数没复位现象游戏结束按 R 重新开始黑块下落速度比初始快很多分数清零了但连击数还在。原因reset 里没有重置 m_intervalMs也没有把 QTimer 重新 start只是停表后 update 了一下。解决reset 方法里 m_score、m_intervalMs、m_columns 三项全部初始化m_timer.setInterval(m_intervalMs)之后重新 start。速度参数最容易犯的错误是只赋一次初值然后后面每一处加速都直接改 m_intervalMsreset 时又忘了把旧值覆盖回来。5.6 CPU 占用偏高嵌入式板子发热现象在 x86 上跑得很流畅换到嵌入式 Linux 板子上 CPU 占用飙到 30% 以上画面还掉帧。原因update() 每次把整张画布全部重绘加上 QTimer 的 interval 设得太激进CPU 时间全浪费在重绘白底和黑块上了。解决先把绘制的重绘范围缩小用update(QRect)只刷新变化行区域再在 paintEvent 里加断点判断事件区域非必须重绘的行直接 continue。如果板子支持把Qt::WA_OpaquePaintEvent属性打开让 Qt 不再额外合成背景层。这个坑在跑 Qt 5.15 的 ARM 板上几乎必踩越早优化越省钱。6. 手感再进一步插值滚动动画与嵌入式 Linux 板上的性能验证画面一格格跳着走能玩但手感发硬尤其速度到 80ms/行时整屏看起来像幻灯片。我一般会把“每隔多少毫秒移动一格”改成“根据真实流逝时间计算像素偏移”这样即便某次定时器触发晚了黑块也只是在下一帧多移动一点视觉上依然是平滑的QElapsedTimer m_elapsed; qreal m_offsetY 0.0; const qreal m_pixelsPerSec 256.0; void GameWidget::onTick() { const qreal deltaSec m_elapsed.restart() / 1000.0; m_offsetY m_pixelsPerSec * deltaSec; if (m_offsetY height() / qreal(m_rowCount)) { m_offsetY - height() / qreal(m_rowCount); advanceRows(); } update(); }这个方案的要点是行距和速度都用浮点累加advanceRows仍然只负责数据推进绘制时再把 m_offsetY 加进每一行的 y 坐标。注意 QElapsedTimer 在 Linux 下走的是 monotonic clock不会被系统时间跳变干扰。性能验证别靠肉眼感觉。跑起来后用top -p $(pgrep nowhiteblock)观察 CPU 占用x86 桌面机上这个项目应该在 5% 以下嵌入式板子上如果超过 10%优先检查是否每帧都在做全量重绘。逻辑正确性则用QT_QPA_PLATFORMoffscreen跑自动化测试可以在无人值守环境验证 5000 次随机棋盘没有数组越界。我做这个小游戏最深的教训是先调通事件循环和判定日志再去碰视觉优化否则你分不清卡顿到底是绘制问题还是逻辑问题。先把 qDebug 打印打开确认每一行黑块生成、消除、出界的记录都对再回头改动画。希望帮到你。本文还有配套的精品资源点击获取
返回列表