简介:基于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 + C++17 的最小项目怎么搭
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 和默认的 mkspec,qtbase5-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 | 备选构建方式,本文用 CMake |
2.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时的速度 | 手感表现 |
|---|---|---|
| 500 | 64 px/s | 新手熟悉按键 |
| 200 | 160 px/s | 正常速度 |
| 100 | 320 px/s | 需要预判 |
| 80 | 400 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:命中判定必须先找“待命行”再比对列
“别踩白块儿”和“打地鼠”不一样,它不允许你跳行消块,你只能按正在逼近底部判定线的那个黑块。如果允许任意黑块被消除,玩家可以先消上面的、漏掉下面的,游戏就失去规则意义了。数据上用QVector<int> 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_PLATFORM=offscreen ./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 里完成命中判定后,立刻把该行标记为 -1;clearRow 函数里同时对同一行做幂等保护。调试时给 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_intervalMs,reset 时又忘了把旧值覆盖回来。
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_PLATFORM=offscreen跑自动化测试,可以在无人值守环境验证 5000 次随机棋盘没有数组越界。
我做这个小游戏最深的教训是:先调通事件循环和判定日志,再去碰视觉优化,否则你分不清卡顿到底是绘制问题还是逻辑问题。先把 qDebug 打印打开,确认每一行黑块生成、消除、出界的记录都对,再回头改动画。希望帮到你。
本文还有配套的精品资源,点击获取