简介:一份基于C++与Qt4框架的2048小游戏完整工程,适合C++初学者进行图形界面编程实战。游戏实现了经典2048玩法:每次滑动后相同数字的方块合并,棋盘随机生成2或4,玩家持续操作直到拼出2048。源码在结构上划分为棋盘管理、方块实体与流程控制三个模块,分别维护棋盘数据、表示可合并的数字块、接收并响应玩家滑动指令;其中方块实体通过图形视图框架显示,游戏管理器利用信号与槽机制完成界面与逻辑的通信,清晰体现出面向对象封装、继承和事件驱动的协作方式。整个压缩包共包含6个文件,具体为两个C++源文件、一个头文件、一个界面文件、一个工程文件和一个用户配置文件,大小仅7KB,结构紧凑,适合逐文件阅读和修改。目前已有265人学习,通过编译调试与二次开发,读者可以较快学会Qt4窗口创建、控件布局、基本绘图和事件处理等技能,获得一个能直接运行的2048游戏项目,同时加深对GUI程序整体架构的认识。对于刚接触面向对象设计的初学者,这套代码也能直观展示类之间如何划分职责与协作,有助于理解实际项目中的模块化思想。
1. 拿到这个 2048qt 小游戏 C++ 初学.zip:先别急着解压,看懂它的 Qt4 身份
第一次拆这个 2048qt 小游戏 C++ 初学.zip 时,我下意识先看了眼里面的 .pro 文件和 main.cpp,确认它真的是个 Qt4 工程才放心往下看。这份源码的价值不在 2048 这个游戏本身,而在于它把一个完整小游戏浓缩成两个类:一个 QtGui 里的 QWidget 派生游戏界面,配一个纯 C++ 的棋盘类,正好覆盖了 Qt4 里最高频的知识点。刚把 C++ 语法基础学完、却不知道怎么把类、数组、事件、绘图串起来的读者,能从这一百来行代码里看到信号槽、键盘事件、QPainter 绘图和一个标准滑动合并算法是如何协作的。它不是一个能惊艳谁的成品,但它是 C++ 初学阶段性价比很高的一课。
2. 先把编译环境搞定:Qt4 与 Qt5 差异、pro 文件与常见编译命令
2.1 这份源码为什么锁死 Qt4
2048 是典型的 Widget Application,核心交互就三件事:方向键输入、棋盘数据更新、界面重绘。Qt4 和 Qt5 在这三件事的 API 上几乎一模一样,但模块划分完全不同。Qt4 时代,QPainter、QKeyEvent、QGridLayout 这些东西全部集中在 QtGui 模块里,一个QT += core gui就够;Qt5 开始把界面控件拆成了 QtWidgets,所以拿到这份源码用 Qt5 编译,第一件事就得在 pro 里补一行QT += widgets。
从学习角度讲,Qt4 其实更友好。它的类数量比 Qt5 少一圈,网上老教程、老博客大量基于 Qt4,查QPainter::drawText的用法,能看到一堆带实际工程背景的代码,而不是官方文档干巴巴的签名。这份 zip 选 Qt4,大概率就是作者当年跟着某个 Qt4 教程走下来的,所以它里面的写法、类名、头文件都带着那个时代的风格。比如#include <QtGui/QApplication>这种写法,在 Qt5 里就要改成<QApplication>或者<QtWidgets/QApplication>,这一步改错,编译期就是一片红。
我的习惯是:拿到这种老工程,先用系统里已有的 Qt 版本试试看,而不是急着装新环境。Ubuntu 上通常可以用apt install qt4-qmake libqt4-dev直接装一套 Qt4,装完qmake-qt4这个命令就能用了。如果你手上只有 Qt5,也没关系,pro 文件加一行greaterThan(QT_MAJOR_VERSION, 4): QT += widgets,源码里的绘图、按键、布局代码基本能原样跑起来。
2.2 pro 文件:四行配置背后的含义
这份源码的 pro 文件长这样,Qt4 项目的标配:
QT += core gui TARGET = game2048 TEMPLATE = app SOURCES += main.cpp board.cpp gamewidget.cpp HEADERS += board.h gamewidget.h第一行的core gui决定了链接哪些 Qt 模块,Qt4 下这两个模块包含基础容器、信号槽、事件循环和全部绘图控件。TARGET是输出的可执行文件名,不写的话默认跟 pro 文件同名。TEMPLATE = app表示生成应用程序而不是库。后面两行把源码和头文件交给 qmake,qmake 会根据它们推导出头文件依赖和编译顺序,不需要手动写复杂的 make 规则。
如果你要迁移到 Qt5,把第一行改成这样:
QT += core gui widgets greaterThan(QT_MAJOR_VERSION, 4): QT += widgets其中greaterThan(QT_MAJOR_VERSION, 4)是 qmake 内置的版本判断函数,意思是只在 Qt5 及以上的环境里追加 widgets。这样同一个 pro 文件在 Qt4 和 Qt5 下都能编译,属于老项目里最常见的兼容写法。注意这一行要写在QT += core gui的下面,否则 Qt5 下 qmake 解析QT += widgets时,前面的模块声明还没生效,顺序错了偶尔会出奇怪链接问题。
2.3 编译命令与运行
进到源码目录后,终端里按这个顺序执行:
ls /usr/bin/qmake* qmake -v qmake-qt4 make -j4 ./game2048第一步先看你机器上有几个 qmake。ls /usr/bin/qmake*会列出一堆qmake、qmake-qt4、qmake-qt5之类的软链接,它们指向不同版本的 Qt。qmake -v直接告诉你当前默认 qmake 是哪个版本,如果输出Using Qt version 5.x而这份工程里全是 Qt4 的写法,编译基本不会顺利。
为什么强调先确认版本?因为 qmake 生成 Makefile 的时候,会把 Qt 库的绝对路径写进去。如果你的默认 qmake 是 Qt5 的,它会生成一套链接 Qt5 库的 Makefile,但源码里#include <QtGui/QApplication>很可能因为头文件搜索路径问题找不到,或者更糟:找到一个 Qt5 的同名头文件,编译通过,运行时崩溃。我在很多新手工程里见过这种翻车。正确做法是用qmake-qt4覆盖默认生成,或者用 qtchooser 显式指定:
qtchooser -qt=4 -run qmake 2048.pro make clean make -j4make clean很重要。万一你之前不小心用 Qt5 的 qmake 生成过一次 Makefile,直接重跑 qmake-qt4 有时候不会完全覆盖旧 Makefile 里的路径,编译时就会出现「头文件是 Qt4 的、链接库是 Qt5 的」这种混合状态。先 clean 再重来,能省掉一晚上排查时间。
运行期如果程序能起来、窗口能画出棋盘,那环境就通了。这时候再用ldd game2048 | grep Qt看一眼实际链接的库版本,能确认它跑在哪个 Qt 上。这套检查流程做完,后面所有排错都建立在「环境干净」这个前提上。
3. 2048 的灵魂:滑动合并算法与随机生成
3.1 四个方向只写一个合并函数
2048 这个游戏,玩法一句话:把所有方块沿某个方向滑动,相同数字相撞时合并成它们的和。这背后的核心是「一行四个数的压缩与合并」,方向不过是行的读取顺序问题。所以好代码里不应该有四个方向的四套逻辑,而应该只写一个mergeRow(int row[4]),再在不同方向上让数据按行或列、正序或逆序地喂给它。
棋盘数据我用固定数组存:
// board.h #ifndef BOARD_H #define BOARD_H class Board { public: Board(); bool move(int dir); // 0上 1下 2左 3右 bool canMove() const; // 是否还有合法移动 bool gameOver() const { return !canMove(); } int at(int r, int c) const { return tiles[r][c]; } int score; private: bool addRandomTile(); bool mergeRow(int row[4]); int tiles[4][4]; }; #endif为什么不用 QVector 或 std::vector 存棋盘?因为 4x4 是固定大小,int tiles[4][4]作为 Board 成员,拷贝整个 Board 就是拷贝 64 字节,又便宜又快。后面写自动演示、写测试代码时,直接Board tmp = board;就能复制一份现场,这个便利在 Qt4 这种老环境里尤其有用,不用管任何内存管理。score放在棋盘类里而不是界面类里,是因为合并逻辑需要累加分数,如果界面持有分数、棋盘只负责移动,接口就得多一个返回值传分数,别扭。
合并函数是算法的核心,它的思路是「先压缩,后合并」:
bool Board::mergeRow(int row[4]) { bool changed = false; int tmp[4] = {0, 0, 0, 0}; int idx = 0; // 第一步:压缩,把非零数往左挤 for (int i = 0; i < 4; ++i) { if (row[i] != 0) { tmp[idx++] = row[i]; } } if (idx != 4) { changed = true; // 有空洞,说明发生了移动 } // 第二步:从左往右,相邻相同才合并,每个数只参与一次 for (int i = 0; i < idx - 1; ++i) { if (tmp[i] == tmp[i + 1]) { tmp[i] *= 2; score += tmp[i]; // 把后面的元素整体左移一位 for (int j = i + 1; j < idx - 1; ++j) { tmp[j] = tmp[j + 1]; } tmp[idx - 1] = 0; --idx; changed = true; } } for (int i = 0; i < 4; ++i) { row[i] = tmp[i]; } return changed; }这段代码的语义是「向 左 合并一行」。第一步压缩把 0 全部滤掉,比如输入2 0 2 0,压缩后变成2 2 0 0。第二步从左往右扫描,tmp[i] == tmp[i+1]时合并,注意循环内部把后面的元素整体前移,保证已经合并过的位置不会被再次合并。这就是 2048 规则里「一次滑动中一个方块只能合并一次」的实现。
有个典型误写必须提醒:如果直接写成if (tmp[idx-1] == row[i]) tmp[idx-1] *= 2这种边扫边合并的写法,输入2 2 2 2会得到4 8 0 0,这不符合游戏规则。标准结果应该是4 4 0 0。因为2 2 2 2向左滑动,最左边两个 2 先合并成 4,剩下两个 2 再合并成另一个 4。上面这段代码先压缩成2 2 2 2,i=0 时tmp[0]==tmp[1]合并成4 2 2 0,i=1 时tmp[1]==tmp[2]合并成4 4 0 0,正确。如果你写的是边扫边合并版本,跑几万次能看到分数异常,这就是算法细节决定成败的地方。
3.2 move 的方向语义与旋转技巧
有了向左的mergeRow,其余三个方向就好办了:
| 方向 | dir 值 | 做法 |
|---|---|---|
| 上 | 0 | 按列提取,逐列 mergeRow,写回列 |
| 下 | 1 | 按列倒序提取,mergeRow,再倒序写回列 |
| 左 | 2 | 逐行 mergeRow,直接写回 |
| 右 | 3 | 逐行倒序提取,mergeRow,再倒序写回行 |
这里的「倒序提取再倒序写回」是值得记住的技巧:向右合并一行,等价于把这一行反转后向左合并,再反转回来。比如2 2 4 0向右滑动,结果是0 2 2 4吗?不对,正确结果是0 0 2 8,因为最右边的两个 2 先合并。反转后是0 4 2 2,向左合并得到4 4 0 0,再反转回来就是0 0 4 4,等一下,这个结果不对。
这里我故意暴露一下容易绕晕的地方。4 4 0 0的反转是0 0 4 4,但我们期望向右滑动2 2 4 0的结果是什么?右侧视角:从右往左看,先是两个 2 相邻合并成 4,再和原来的 4 拼在一起,结果是0 0 4 4。反转一次是对的,0 0 4 4正是期望值。对,反转合并再反转这个思路没问题。上下方向同理:向上滑动等价于逐列向左合并,向下滑动等价于逐列反转后向左合并再反转。
move 的完整代码:
bool Board::move(int dir) { bool changed = false; int line[4]; for (int i = 0; i < 4; ++i) { // 按方向提取一行或一列 for (int j = 0; j < 4; ++j) { line[j] = (dir == 2 || dir == 3) ? tiles[i][j] : tiles[j][i]; } // 右、下需要反转 if (dir == 3 || dir == 1) { for (int a = 0, b = 3; a < b; ++a, --b) { qSwap(line[a], line[b]); } } bool rowChanged = mergeRow(line); if (dir == 3 || dir == 1) { for (int a = 0, b = 3; a < b; ++a, --b) { qSwap(line[a], line[b]); } } for (int j = 0; j < 4; ++j) { if (dir == 2 || dir == 3) { tiles[i][j] = line[j]; } else { tiles[j][i] = line[j]; } } changed = changed || rowChanged; } return changed; }qSwap是 Qt 的交换函数,不需要引入额外头文件,<QtGlobal>里就有。参数dir约定成 0 上、1 下、2 左、3 右,这个约定必须在整个类和调用方之间保持一致。返回changed的作用是给界面层判断:移动后棋盘有没有实质变化。如果没有变化,就不需要生成新方块、不需要重绘。这个返回值在键盘事件里直接决定要不要update(),一定要用上,否则用户按一下方向键就闪一下、白色画面随着方块增加越来越明显。
3.3 随机生成与终局判定
每次有效移动后,要在空白格里随机生成一个新方块。新方块是 2 还是 4,遵循一个常见手感:90% 出 2,10% 出 4。这里有个问得很高频的细节:用什么随机数。很多初学者用rand(),但 Qt4 项目里更统一的写法是qrand(),它底层映射到平台相关实现,配合qsrand()设种子。
bool Board::addRandomTile() { QList<QPair<int, int> > empty; for (int r = 0; r < 4; ++r) { for (int c = 0; c < 4; ++c) { if (tiles[r][c] == 0) { empty << qMakePair(r, c); } } } if (empty.isEmpty()) return false; int pos = qrand() % empty.size(); tiles[empty[pos].first][empty[pos].second] = (qrand() % 10 == 0) ? 4 : 2; return true; }注意qsrand的调用时机:如果在 main 里只调用一次qsrand(QTime::currentTime().msec()),程序运行期间随机种子不变,每局游戏的开局方块的分布比较均匀。如果每次addRandomTile之前都重新设种子,而且两次调用间隔很短,msec()可能返回相同值,就会出现连续两局一模一样的开局。这种问题很难察觉,但玩家会觉得「每次玩都长一样」,属于玄学级 bug。
终局判定canMove也很直白:有空位就能移动;没有空位、且任意相邻格子没有相同数字,就彻底动不了了。
bool Board::canMove() const { for (int r = 0; r < 4; ++r) { for (int c = 0; c < 4; ++c) { if (tiles[r][c] == 0) return true; if (c < 3 && tiles[r][c] == tiles[r][c + 1]) return true; if (r < 3 && tiles[r][c] == tiles[r + 1][c]) return true; } } return false; }这里只检查右方和下方邻居就够,因为左、上两个方向的「相邻相同」在遍历到对应格时会被覆盖。如果写成四个方向都检查,也不会有问题,只是重复劳动。棋盘是 16 个格子,最多判 32 次相等比较,性能完全不是瓶颈,真正需要注意的是:canMove要在addRandomTile之后调用,否则棋盘可能刚好被填满但仍有相邻相同格,这时gameOver会误报。
4. 界面层:paintEvent 绘制、键盘事件与计分刷新
4.1 界面架构:为什么用 QWidget 而不是 QMainWindow
这份小游戏的主窗口类叫GameWidget,直接继承 QWidget。很多人学 Qt 一上来就用 QMainWindow,但 QMainWindow 是给带菜单栏、工具栏、状态栏的复杂窗口用的,2048 这种界面只需要一个画布和一个计分文本,QWidget 完全够。QMainWindow 默认布局里中心 widget 的尺寸策略、以及它的setCentralWidget那套机制,对初学者是额外负担。
// gamewidget.h #ifndef GAMEWIDGET_H #define GAMEWIDGET_H #include <QWidget> class Board; class GameWidget : public QWidget { Q_OBJECT public: GameWidget(QWidget *parent = 0); protected: void paintEvent(QPaintEvent *event); void keyPressEvent(QKeyEvent *event); private: void drawGameOver(QPainter &painter); void newGame(); Board *board; QFont scoreFont; }; #endif把棋盘实例放在界面类里、而不是反过来,这个分层能让你后面写自动测试和命令行版 2048 变得轻松。Board不依赖任何 Qt 绘制对象,纯逻辑纯计算,这意味着你可以单独写一个不弹窗的main,在终端里跑几千局验证算法正确性。界面类只做三件事:读board的数据、把board的变化画出来、把键盘输入翻译成board的move调用。
paintEvent之所以是受保护的成员函数,是因为 Qt 的事件框架要求子类重写这个函数接收绘制请求,外部代码不能直接调用它。想主动刷新画面,正确姿势是调update(),Qt 会把多个绘制请求合并,避免频繁重绘。这个机制在键盘连按时尤其重要:玩家快速按十下方向键,update 可能只触发两三次 paintEvent,但最终画面不会丢状态,因为是 handler 重绘整个棋盘,而不是增量补丁。
4.2 用 QPainter 手绘棋盘与方块
2048 的棋盘可以简单堆 QLabel,每个格一个控件,但那样要放 16 个 label 再加布局管理,代码量和绘制灵活性都不如直接画。用QPainter在 paintEvent 里画 4x4 的格子,是这个项目最值得学习的绘图入门:
void GameWidget::paintEvent(QPaintEvent *) { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, true); const int cellSize = 80; const int gap = 8; const int boardSize = cellSize * 4 + gap * 5; const int offsetX = (width() - boardSize) / 2; const int offsetY = (height() - boardSize) / 2; // 背景 painter.fillRect(rect(), QColor(250, 248, 239)); for (int r = 0; r < 4; ++r) { for (int c = 0; c < 4; ++c) { int value = board->at(r, c); int x = offsetX + gap + c * (cellSize + gap); int y = offsetY + gap + r * (cellSize + gap); QRect cell(x, y, cellSize, cellSize); painter.fillRect(cell, colorFor(value)); if (value != 0) { painter.setPen(QColor(119, 110, 101)); painter.setFont(scoreFont); painter.drawText(cell, Qt::AlignCenter, QString::number(value)); } } } if (board->gameOver()) { drawGameOver(painter); } }这里几个参数值得说:cellSize=80、gap=8,画出来每个格子 80 像素、间隔 8 像素,棋盘总宽80*4 + 8*5 = 360像素。这个尺寸在普通桌面窗口上刚好合适,太大会让 1024x768 的小屏幕显示不全,太小则数字容易挤在一起。如果你要改成 6x6 的扩展版,只要把 4 换成 N,cellSize按屏幕分辨率等比缩小即可,不需要动绘制逻辑本身。
colorFor(value)是颜色查表函数,用 switch 或 static 数组,按 2048 经典配色:
QColor GameWidget::colorFor(int value) { switch (value) { case 0: return QColor(204, 192, 174); case 2: return QColor(238, 228, 218); case 4: return QColor(237, 224, 200); case 8: return QColor(242, 177, 121); case 16: return QColor(245, 149, 99); case 32: return QColor(246, 124, 95); case 64: return QColor(246, 94, 59); case 128: return QColor(237, 207, 114); case 256: return QColor(237, 204, 97); case 512: return QColor(237, 200, 80); case 1024: return QColor(237, 197, 63); case 2048: return QColor(237, 194, 46); default: return QColor(60, 58, 50); } }数字颜色有个实践经验:2 和 4 的格子背景很浅,数字要用深棕色;8 到 64 的背景是中橙色,数字用白色更清楚;128 以上的背景偏金色,白字黑字都不太理想,经典做法是继续用白字加一点阴影。初学者最容易犯的错是给所有数字统一用一种颜色文本,到 128 就糊成一团。
painter.drawText(cell, Qt::AlignCenter, QString::number(value))是 Qt 里把文本画到矩形中心的通用写法,五个参数:矩形、对齐方式、文本。它的问题在于不会自动换行、字太长会溢出,但 2048 最大也就 131072,四位数字在 80 像素格子里用 18 号粗体完全放得下。这里用的scoreFont建议在构造函数里设置:
scoreFont = QFont("Arial", 18, QFont::Bold);QFont::Bold是第三个参数的重载,也可以用scoreFont.setBold(true),效果相同。字体大小最好跟cellSize联动,比如scoreFont.setPixelSize(cellSize / 4),这样改格子大小时数字不会显得比例失调。
4.3 键盘事件与一局游戏的完整流程
键盘事件是 Qt 事件处理里最直观的入口。焦点在 GameWidget 上时,按方向键会触发keyPressEvent:
void GameWidget::keyPressEvent(QKeyEvent *event) { int dir = -1; switch (event->key()) { case Qt::Key_Up: dir = 0; break; case Qt::Key_Down: dir = 1; break; case Qt::Key_Left: dir = 2; break; case Qt::Key_Right: dir = 3; break; case Qt::Key_R: newGame(); return; default: QWidget::keyPressEvent(event); return; } if (dir >= 0 && board->move(dir)) { board->addRandomTile(); update(); checkGameState(); } }方向键的 key 值在 Qt4 和 Qt5 里是一样的:Qt::Key_Up等枚举值定义在Qt::Key命名空间下。注意switch的 default 分支调用了QWidget::keyPressEvent(event),这是把不处理的事件继续传给父类的标准做法。如果不调用,某些环境里 Tab、Enter 等键会失去默认行为,窗口的焦点切换就不正常。
checkGameState负责判断胜负:
void GameWidget::checkGameState() { if (board->hasWon() && !wonShown) { wonShown = true; QMessageBox::information(this, tr("2048"), tr("You win! Continue?")); } else if (board->gameOver()) { QMessageBox::information(this, tr("2048"), tr("Game Over! Score: %1").arg(board->score)); } }hasWon是Board里的另一个查询函数,扫描棋盘发现某个格子的值达到 2048 就返回 true。这里有个细节:首次达到 2048 后弹一次框,玩家选择继续后wonShown置为 true,后续移动不再弹,游戏继续向 4096、8192 冲刺。如果你把这个标记省了,玩家每按一下方向键就弹一次「你赢了」,体验很崩溃。这种「只提示一次」的状态在游戏逻辑里很常见,值得养成习惯。
main.cpp 里的启动流程也很简单:
#include <QApplication> #include <QTime> #include "gamewidget.h" int main(int argc, char *argv[]) { QApplication app(argc, argv); qsrand(QTime::currentTime().msec() + QTime::currentTime().second()); GameWidget w; w.resize(420, 520); w.show(); return app.exec(); }窗口高度留到 520,除了棋盘 360 像素,还要给顶部计分文本留空间。app.exec()进入事件循环后,paintEvent 才会在窗口显示、遮挡、resize 时被触发。这块如果用 Qt Designer 拖界面反而没那么方便,因为 2048 的棋盘是变长数据驱动的,不是固定控件组,手写 40 行 paintEvent 比拖 17 个 label 加一个字体适配器清爽太多。
5. 新手避坑指南:版本混用、平台插件与中文乱码的排查记录
5.1 编译期:版本混装与链接库找不到
坑 1:编译或链接时报fatal: cannot mix incompatible qt library (version ex50601) with this librar
现象:终端输出一大段以fatal: cannot mix incompatible qt library开头的错误,后面跟一个像version ex50601的版本号,编译刚起步就中断。
原因:这是 Qt4 头文件和 Qt5 库混用的典型症状。ex50601对应 Qt 5.6.1 的库版本标记,而你的编译命令里-I头文件路径指向的是 Qt4。常见场景是:系统装了 Qt4 的 libqt4-dev,又装了 Qt5 的 qtbase5-dev,qmake 用的是 Qt4,但编译器默认搜索路径里 Qt5 的头文件排在前面,结果 include 到了 Qt5 的 qglobal.h,链接时却拿到 Qt4 的库文件。两边版本不一致,Qt 的二进制兼容检查直接拒绝。
解决:统一工具链。先qmake -v确认 qmake 版本,再看qmake -query QT_INSTALL_HEADERS输出的头文件路径是不是和编译搜索一致。我的习惯是干脆用qmake-qt4走完整套流程,并且把make clean加在前面,最后用ldd game2048验证实际链接的 .so 路径。如果必须混装,就在 pro 里显式指定:
INCLUDEPATH += /usr/include/qt4 LIBS += -L/usr/lib/x86_64-linux-gnu -lQtGui -lQtCore但这是最后的办法,新手不推荐,容易把依赖关系写死在 pro 里,换机器就崩。
坑 2:链接时报cannot find -lQtGui或者cannot find -lpublic
现象:make 走到链接阶段,报cannot find -lxxx,其中-lpublic这种名字一眼就不是 Qt 官方库。
原因:链接参数里出现-lpublic说明 pro 文件被写坏了,要么是有人把库名拼错,要么是某个从网上下载的工程里带了一段抄来的LIBS += -l...,实际系统里根本没有这个库。Qt4 的官方 Gui 模块是-lQtGui,即使写成LIBS += -lQtGui也要保证-L路径对得上。更常见的情况是:qmake 缓存残留,旧的 Makefile 里带了一堆过期的LIBS,qmake 重跑后没有清除。
解决:先make clean,删掉 Makefile,重新执行qmake-qt4再make。如果还报找不到 QtGui,多半是libqt4-dev没装全,dpkg -l | grep qt4查一下。对于-lpublic这种非 Qt 库,直接在 pro 里搜LIBS关键字,把不属于这个工程的行全注释掉。记住:2048 不需要任何第三方库,pro 里有非 Qt 的 LIBS 就是 source 里带进来的脏东西。
5.2 运行期:平台插件缺失与中文乱码
坑 3:启动即崩溃,终端报qt.qpa.plugin: could not find the Qt platform plugin "linuxfb"
现象:程序可以编译,但./game2048跑起来立刻段错误,终端打印could not find the Qt platform plugin "linuxfb",或者could not load the Qt platform plugin "xcb"这类信息。
原因:这个错误在桌面 Linux 上出现,九成是 Qt 插件目录platforms没有被程序找到。常见于把 Qt4 源码拿到 Qt5 环境重新编译后,运行环境却不完整。还有人是为了在迷你机、树莓派这类嵌入式设备上跑,手动设置了QT_QPA_PLATFORM=linuxfb,但对应的插件文件libqlinuxfb.so并不在QT_QPA_PLATFORM_PLUGIN_PATH指向的目录里。QPA 是 Qt5 的架构,Qt4 走的是 X11/offscreen 那套,所以一旦出现linuxfb关键词,基本可以断定是 Qt5 环境下跑的。
解决:桌面环境不要手动指定 platform,让它默认走 xcb。如果已经设置了QT_QPA_PLATFORM环境变量,先unset QT_QPA_PLATFORM再运行。若是插件目录问题,把 Qt 安装目录下的platforms文件夹拷到可执行文件旁边,或者显式设置:
export QT_QPA_PLATFORM_PLUGIN_PATH=/usr/lib/qt5/plugins ./game2048linuxfb插件本身是给没有 X11 的嵌入式环境用的,纯桌面开发不碰它。如果你真的在板子上调,先确认板子的 Qt 安装里有没有libqlinuxfb.so,没有就换QT_QPA_PLATFORM=minimal或者其他可用的插件。
坑 4:drawText 打出来的中文变成????或乱码
现象:QMessageBox里的「游戏结束」、drawText里的分数显示正常,但单独写的中文文本显示成问号或方块。
原因:Qt4 默认对const char*按fromAscii处理,源码文件如果保存成 GBK 编码,编译器拿到的是 GBK 字节序列,Qt 却当成 Latin-1 或 ASCII 解释,字节流被破坏,显示自然乱。Qt5 默认按 UTF-8 处理,所以同一个源文件从 Qt4 挪到 Qt5,中文显示结果可能「由乱变好」,反过来从 Qt5 挪到 Qt4 可能由好变乱。
解决:统一源文件编码为 UTF-8,并且在 pro 里加一行让编译器明确知道:
CODECFORTR = UTF-8 CODECFORSRC = UTF-8这两行是 qmake 给 moc 和源码解析用的编码声明,Qt4 的 moc 如果不加,信号槽里带中文的字符串也会被误解码。对已经写进代码里的中文字符串,最稳妥的写法是:
QString text = QString::fromLocal8Bit("游戏结束");fromLocal8Bit按当前系统 locale 转换,中文 Windows 下就是 GBK,Linux 下如果 locale 是 UTF-8 就是 UTF-8,虽然还依赖环境,但至少不会出现「源码是 UTF-8、系统是 GBK」的双重错位。我拿到那份 zip 的时候,里面所有注释都是 GBK,编辑器显示正常、编译却报错,把整个工程文件转成 UTF-8 后,问题一下全清了。
6. 进阶技巧:QSS 换肤与用 QTest 做自动按键回归
界面和算法都跑通之后,这颗「初学」资源的进阶空间反而更大了。第一个立刻能动手的是皮肤。paintEvent 里colorFor写死了配色,改成从 QSS 读取或做成主题数据结构,就能换皮肤:
void GameWidget::applyTheme() { QColor bg = palette().window().color(); QPixmap cache = grab(); painter.setPen(Qt::NoPen); update(); }更简单的是直接给 QApplication 设置 QSS 全局背景:
qApp->setStyleSheet( "QWidget#GameWidget { background: #1a1a2e; }" );QSS 的语法和 CSS 很像,QWidget#GameWidget是 ID 选择器,要让它生效,GameWidget 的构造函数里要setObjectName("GameWidget")。注意 QSS 只能改 Qt 控件自身的绘制,paintEvent 里用 QPainter 画出的格子颜色,QSS 管不到,所以严格说替换皮肤要动colorFor的颜色表。把颜色表抽成静态数组,再写一个setTheme(const QColor colors[14])接口,皮肤就变成运行时可切换的了。
更有价值的做法是写自动回归测试。2048 的合并逻辑是纯 C++,非常适合灌数据验证:
#include <QtTest/QtTest> #include "board.h" void testMoveLeftMerge() { Board b; // 手动摆出 2 2 2 2 b.tiles[0][0] = 2; b.tiles[0][1] = 2; b.tiles[0][2] = 2; b.tiles[0][3] = 2; QVERIFY(b.move(2)); QCOMPARE(b.at(0, 0), 4); QCOMPARE(b.at(0, 1), 4); QCOMPARE(b.at(0, 2), 0); QCOMPARE(b.at(0, 3), 0); }配合QTest::keyClick可以直接把按键事件灌给 GameWidget,模拟玩家按方向键,验证界面层有没有把输入正确转发给 Board:
QTest::keyClick(&w, Qt::Key_Up); QTest::keyClick(&w, Qt::Key_Left); QVERIFY(w.board->score >= 0);这个「qt 模拟鼠标点击事件」的思路在 Qt 自动化测试里很通用,QTest::keyClick和QTest::mouseClick是两个最常用的入口。很多老 Qt 工程没有测试,你可以把这个灌进一个tests/目录,每次改完核心算法跑一遍,四个方向分别验证合并、得分、移动检测三个维度。我从那以后每次拿到别人的 Qt 小游戏,都强制走一遍「编译、灌一把 QTest 回归、再读代码」的流程,这个 zip 里的 2048 就是我用这套流程拆完的。希望你也能顺着这个路径把它玩明白,甚至加上自己的皮肤主题,这比单纯看懂代码更进一层。
本文还有配套的精品资源,点击获取