简介:这份2048小游戏工程面向具备C++基础、希望借小型项目入门Qt4图形界面开发的初学者,以经典数字合并玩法为载体,帮助读者理解面向对象设计、事件处理与GUI绘制流程。压缩包共6个文件,约7KB,主要由2个cpp源文件、1个h头文件、1个ui界面文件及1个pro工程配置组成,另含用户配置记录,体量轻便,便于导入Qt Creator对照阅读与编译调试。已有270人学习下载。工程围绕棋盘状态维护、方块合并规则、得分与结束判定等核心逻辑展开,可观察到QGraphicsView/QGraphicsItem绘制思路以及信号槽通信方式;对于想从控制台程序过渡到桌面应用的读者,也能借此熟悉头源文件组织、ui与pro配置的配合,适合作为课程设计、自学练手和Qt4入门实践的参考素材。
1. 一个 4×4 的棋盘,能把 Qt4 的事件循环、绘制和信号槽串成一条线
学 C++ 最难受的阶段不是语法,是你写完五六个类之后,不知道它们怎么在一个窗口里跑起来。这份2048qt小游戏C++初学.zip里放的就是一个用 Qt4 写的 2048 桌面小游戏源码:4×4 棋盘、方向键操作、分数与最高分、方块滑动。它面向的人群很明确——刚学完 C++ 基础语法、还写不出带界面的程序的新手,以及想找个干净小工程、把 Qt4 的信号槽和事件重载重新过一遍的老手。2048 的规则两句话能讲完,但它把状态管理、键盘事件、随机落子、重绘、持久化全用上了。把这份代码编译一遍、改两个参数、再故意弄崩一次,比看三天教程收获都大。
2. 工程骨架与 qmake:先把能编译这件事解决掉
拿到一份 Qt 源码,第一件事不是读代码,是让它编译通过。新手最容易在这一步放弃:.pro文件里少一行模块声明,或者用 Qt5 的环境去编译一份 Qt4 的工程,报错信息能刷满一屏,看着像代码全错,其实一个字都没错。这一章先把环境差异和工程骨架摊开,编译过了再谈逻辑。
2.1 Qt4 和 Qt5 的差异:先认清手里这套代码站在哪一边
Qt4 是一个时代感很强的版本。它的模块划分、信号槽写法、随机数接口和今天的 Qt5/Qt6 都不一样,而这些差异恰好全都会在编译阶段爆发。判断手里的代码属于哪一边,最直接的办法是看两处:.pro里有没有QT += widgets,头文件里写的是#include <QtGui>还是#include <QWidget>。前者是 Qt5 的写法,后者是 Qt4 的习惯写法。
几个必须知道的差异点:
| 差异点 | Qt4 的写法 | Qt5 及以后的写法 | 踩坑表现 |
|---|---|---|---|
| 控件模块 | widgets 属于gui模块 | 独立的widgets模块 | 报QWidget: No such file or directory |
| 头文件 | #include <QtGui>一把抓 | #include <QWidget>按类引入 | 找不到类定义 |
| 信号槽 | 只能用SIGNAL()/SLOT()宏 | 可用函数指针写法,编译期检查 | 槽函数名拼错,运行时才报No such slot |
| 随机数 | qsrand()/qrand() | QRandomGenerator(较新版本才有) | 换了环境随机数完全不随机 |
| C++ 标准 | 默认不一定开 C++11 | 默认开 | nullptr、auto、范围 for 报错 |
| 高分屏 | 不支持自动缩放 | 有高 DPI 缩放开关 | 界面在 4K 屏上小得看不清 |
判断完版本,再去动代码。如果手上是 Qt5 环境要编译 Qt4 的工程,最小改动是两处:.pro里补greaterThan(QT_MAJOR_VERSION, 4): QT += widgets,源码里把#include <QtGui>换成具体的类头文件。这两处改完,八成的编译错误会消失,剩下的才是真问题。
还有一点关于随机数,值得单独提醒。Qt4 的qrand()在没有qsrand()的情况下,每次启动产生的序列完全一样——你会看到每次开局第一个方块都落在同一个格子上。这不是代码有 bug,是种子没设。常见做法是在main()里用当前时间做种子,具体写在 3.3 节。
2.2 .pro 文件逐行拆解,以及两条构建命令
qmake 的工程文件看着简单,但它决定了编译器去哪找头文件、链接哪些库、开不开 C++11。新手拿到压缩包直接双击.pro,IDE 会自动生成一套默认配置,然后编译失败,原因往往就是默认配置里少了东西。我一般不看 IDE 生成的,直接手写一份,短但可控:
# game2048.pro TEMPLATE = app # 生成可执行程序,不是库 TARGET = game2048 # 产物名字,Windows 下会补 .exe # Qt4 的 widgets 在 gui 模块里,Qt5 拆成了独立模块,这一行做兼容 greaterThan(QT_MAJOR_VERSION, 4): QT += widgets QT += core gui CONFIG += c++11 # Qt4 时代默认不开 C++11,这行必须有 CONFIG -= app_bundle # macOS 下不打包成 .app,方便命令行跑 # 开警告:有符号/无符号比较、未使用变量这类问题,早发现省事 QMAKE_CXXFLAGS += -Wall -Wextra HEADERS += board.h \ gamewidget.h SOURCES += main.cpp \ board.cpp \ gamewidget.cpp逐行说明几个容易忽略的点。TEMPLATE = app如果不写,qmake 默认也是 app,但显式写出来能避免复制别人工程时带上lib的配置。greaterThan(QT_MAJOR_VERSION, 4)是一个 qmake 条件表达式,只有主版本号大于 4 才执行后面的追加,所以同一份.pro在 Qt4 和 Qt5 下都能用。CONFIG += c++11在 Qt4 里尤其关键,因为 Qt4 时代编译器默认标准还停在 C++98,代码里只要出现nullptr或者范围 for 就会报一串看不懂的错。
QMAKE_CXXFLAGS += -Wall -Wextra这行在新手眼里纯属自找麻烦——编译时多出一堆警告。但 2048 这类代码里最常见的崩溃就是数组下标越界,而越界往往先以「有符号与无符号整数比较」的警告形式出现。把警告当错误看,是这份工程里最划算的一个习惯。
生成的 Makefile 用两条命令就够:
# 在 .pro 所在目录执行,别跑到 build 子目录里去 qmake game2048.pro # 生成 Makefile;Qt4 环境下命令就叫 qmake make -j4 # Windows + MinGW 工具链用 mingw32-make -j4 ./game2048 # Windows 下产物在 release\game2048.exe-j4是并行编译的线程数,工程只有五个文件,这个参数影响不大,但养成习惯没坏处。真正需要留意的是qmake的版本:机器上同时装了 Qt4 和 Qt5 时,qmake -v输出的版本号决定了一切。如果qmake -v显示的是 5.x,而你手上是 Qt4 工程,那么即使.pro写对了,编译出来的行为也可能和预期不一致。先确认版本,再编译,能省掉大量「明明代码一样为什么结果不同」的困惑。
2.3 QWidget 自绘还是 QGraphicsView:一次影响后续所有代码的选型
2048 的界面有两种典型实现路径。一种是继承QWidget,重写paintEvent,用QPainter把 16 个方块画出来;另一种是上QGraphicsView+QGraphicsScene,每个方块是一个QGraphicsItem。这份新手工程走的是第一条路,理由很实在:代码量小、断点好打、不需要理解 item/scene/view 三层结构。
| 维度 | QWidget + paintEvent | QGraphicsView 场景 |
|---|---|---|
| 入门成本 | 低,一个类就能跑 | 高,要先理解三套坐标系 |
| 动画支持 | 自己算坐标,配QPropertyAnimation | 图元级动画,局部刷新 |
| 重绘粒度 | update()重绘整个窗口 | 按图元脏区刷新 |
| 适合规模 | 4×4、8×8 这类小棋盘 | 上百图元、需要拖拽缩放 |
| 排查难度 | 断点直接落在paintEvent | 事件常被 view 吞掉,绕 |
4×4 一共 16 个方块,全窗口重绘的开销可以忽略不计,选 QWidget 完全没有性能顾虑。真要扩到 20×20,一帧画 400 个方块、每个都带圆角和文字,全量重绘才开始掉帧,那时候再换 QGraphicsView 才划算。选型的判断标准不是哪个更「高级」,而是图元数量和交互复杂度——这一点上新手工程的选择是对的,别一上来就上重型框架。
选型定了之后,类的划分也跟着定:Board只管数据和规则,完全不含 Qt 的绘制代码;GameWidget继承QWidget,只管画和收键盘事件。这个分界线画清楚,后面加动画、加存档、写测试都不会互相牵连。很多新手版本的 2048 把合并逻辑写在paintEvent里,一改就全乱,根源就是这条线没画。
3. 棋盘数据模型:合并顺序决定你写出来的是不是 2048
界面能出来之后,真正决定这个游戏对不对的是合并逻辑。2048 的规则一句话:同方向滑动,相同数字相邻则合并成两倍,每回合每个方块最多参与一次合并。听起来简单,但2 2 2 2这一行就能让一半的新手写出错误结果——正确答案是4 4,不是8,也不是4 2 2。这一章把数据结构和合并算法拆开讲。
3.1 一维数组模拟二维:索引换算和遍历方向的统一
棋盘用int m_cells[16]存,行优先,(r, c)对应m_cells[r * 4 + c]。用一维而不是int[4][4]的理由不是省内存,而是四个方向的移动可以复用同一份逻辑:把一行(或一列)按某个方向取出来变成临时数组,统一「向索引 0 端合并」,再原路写回。如果用二维数组,四个方向要写四套循环,任何一套写错都是隐蔽的 bug。
// board.h —— 数据与规则,不包含任何绘制代码 class Board { public: enum { N = 4 }; Board(); void reset(); // 清空并落两个初始方块 bool move(int dir); // 0 左 1 右 2 上 3 下,返回棋盘是否变化 int at(int r, int c) const { return m_cells[r * N + c]; } int score() const { return m_score; } bool canMove() const; // 还有没有合法走法 bool reached2048() const; private: bool slideLine(int *line, int len); // 单行向索引 0 端压缩合并 void spawnTile(); int m_cells[N * N]; int m_score; };索引换算这一步看着无聊,但它是后面所有 bug 的源头。at(r, c)里写成m_cells[r * N + c],一旦有人写成c * N + r,整个棋盘会转置——现象是上下移动表现得像左右移动,非常迷惑。写完之后拿at(0,1)和at(1,0)各打印一次,两秒钟就能验证。
3.2 单行合并的核心循环:2 2 2 2凭什么变成4 4
合并算法有两种常见写法:一种是先压缩非零元素、再扫描相邻相等、最后再压缩一遍(三趟);另一种是一趟搞定,用dst记录下一个写入位置,用lastMerged记住上一格是否刚合并过。后者代码短,但lastMerged这个标记是灵魂——没有它,2 2 4会错算成8。
// 把一行朝下标 0 的方向压缩合并,返回这一行是否发生变化 bool Board::slideLine(int *line, int len) { bool changed = false; int dst = 0; // 下一个可写入的位置 int lastMerged = -1; // 上一次合并落在哪个目标格,防止连环合并 for (int i = 0; i < len; ++i) { if (line[i] == 0) // 空格直接跳过,后面的数字会挤过来 continue; // 能合并的条件:前面已经有落位、数值相等、且那格本回合没合并过 if (dst > 0 && line[dst - 1] == line[i] && lastMerged != dst - 1) { line[dst - 1] *= 2; m_score += line[dst - 1]; // 分数加的是合并后的值 line[i] = 0; lastMerged = dst - 1; // 打上标记,本回合不能再吃 changed = true; } else { if (dst != i) { // 发生了位置移动 line[dst] = line[i]; line[i] = 0; changed = true; } ++dst; } } return changed; }拿几组数据走一遍,比读注释快。2 2 2 2:第一个 2 落到下标 0,dst变 1;第二个 2 和line[0]相等且未合并,合并成 4,lastMerged记 0;第三个 2 想和line[0](已是 4)比,不等,落到下标 1,dst变 2;第四个 2 和line[1]相等且未合并,合并成 4,结果4 4 0 0。再看4 2 2:4 落位,dst变 1;第一个 2 落到下标 1,dst变 2;第二个 2 和line[1]相等,合并成 4,结果4 4 0,不是8——这正是lastMerged在起作用。
参数说明:len传行长度(4),line是栈上的临时数组,函数直接改它,调用方负责写回。把m_score放进这个函数里加,好处是「合并」和「加分」永远同步,坏处是这个函数不再是纯函数、不好单独测。要写单测,就把它改造成static bool slideLine(int *line, int len, int &scoreDelta),把加分通过引用带出去,这一点在最后一章还会用到。
四个方向的统一靠取元素的顺序:
bool Board::move(int dir) { bool changed = false; for (int i = 0; i < N; ++i) { int line[N]; for (int j = 0; j < N; ++j) { // 按方向把这一行/列取出来 switch (dir) { case 0: line[j] = m_cells[i * N + j]; break; // 左 case 1: line[j] = m_cells[i * N + (N - 1 - j)]; break; // 右:倒着取 case 2: line[j] = m_cells[j * N + i]; break; // 上:取第 i 列 default:line[j] = m_cells[(N - 1 - j) * N + i]; break; // 下:倒着取列 } } if (slideLine(line, N)) { changed = true; for (int j = 0; j < N; ++j) { // 原路写回,顺序必须和取的时候一致 switch (dir) { case 0: m_cells[i * N + j] = line[j]; break; case 1: m_cells[i * N + (N - 1 - j)] = line[j]; break; case 2: m_cells[j * N + i] = line[j]; break; default:m_cells[(N - 1 - j) * N + i] = line[j]; break; } } } } if (changed) spawnTile(); // 只有真的动了才落新方块,这条规则很关键 return changed; }写回时的下标表达式必须和取的时候逐字对应,这是最容易出错的地方。右移取的时候是N - 1 - j,写回的时候写成N - j,j = 0时下标就是N,越界一格——这个 bug 在 Debug 下可能只是数字错乱,在 Release 下就是直接退出。
3.3 随机落子:种子、空格均匀性和 90/10 的概率
随机落子要满足三条:只落在空格上、空格之间等概率、出的数字 90% 是 2、10% 是 4。三条里最容易错的是第二条——不少新手版本写成「随机取一个下标,是空的就放,不是空的重来」,格子快满的时候会疯狂空转,甚至卡死在循环里。
void Board::spawnTile() { int empty[N * N], n = 0; for (int i = 0; i < N * N; ++i) // 先把所有空格收集起来 if (m_cells[i] == 0) empty[n++] = i; if (n == 0) // 没有空格,直接返回 return; int idx = empty[qrand() % n]; // 在空格集合里均匀抽一个 m_cells[idx] = (qrand() % 10 == 0) ? 4 : 2; // 10% 概率出 4 }先收集再抽样,复杂度是 O(16),格子满了也不会死循环。qrand() % 10 == 0表示 0 到 9 里命中 1 个,概率是 10%,这个约定俗成的比例和原版一致;想要更硬核可以改成 5%。qrand() % n的取模偏差在 n ≤ 16 时可以忽略,真要讲究就用拒绝采样,这个规模下没必要。
种子在main()里设一次,别放在构造里:
#include <QTime> #include <QCoreApplication> int main(int argc, char *argv[]) { QApplication app(argc, argv); // 时间做种子,再混入进程号,避免同一秒内启动的两个实例序列相同 qsrand(QTime(0, 0, 0).secsTo(QTime::currentTime()) + QCoreApplication::applicationPid()); GameWidget w; w.show(); return app.exec(); }QTime(0,0,0).secsTo(QTime::currentTime())得到的是一天中的秒数,跨天时会重置,加上进程号基本够用。Qt4 用户注意别去找QRandomGenerator,那个类在 Qt4 里不存在。少了qsrand()这行的典型现象是:每次启动,开局两个方块的位置一模一样。
3.4 死局判定:canMove的两个检查点
游戏结束的条件不是「棋盘满了」,而是「棋盘满了且没有相邻相等的方块」。新手版本常犯的错是只看空格数,导致棋盘满了但还能合并时就被判负。
bool Board::canMove() const { for (int i = 0; i < N * N; ++i) { if (m_cells[i] == 0) return true; // 还有空格,一定能走 int r = i / N, c = i % N; if (c + 1 < N && m_cells[i] == m_cells[i + 1]) return true; // 右边相邻相等 if (r + 1 < N && m_cells[i] == m_cells[i + N]) return true; // 下边相邻相等 } return false; // 空格没有,相邻也都不同 }只需要检查右和下两个方向,因为「左」和「上」的相等关系会被左边、上边的格子各查一遍,重复检查没有意义。c + 1 < N和r + 1 < N这两个边界判断不能省,否则最右列和最下行会读到下一行的数据,误判成可走。这个函数在每次move()之后调用一次就够,别放进paintEvent,那是每帧都在跑。
胜利判定更简单:遍历一遍,有 2048 就弹提示。但要不要「继续玩」是产品决策,新手版本一般弹个对话框就结束,想做得完整一点就让玩家选择继续,reached2048()只作为触发条件。
4. 绘制与交互:QPainter、键盘焦点和动画的边界
逻辑对了,接下来是让它看起来像个游戏。这一块代码量不小,但难度不高,真正的坑集中在两处:一是键盘事件收不到,二是动画和逻辑纠缠在一起导致分数翻倍。把这两处想清楚,剩下的都是调色和排版。
4.1 paintEvent 里画什么:格子尺寸、圆角和配色
绘制只在paintEvent里做,别在别的地方拿QPainter往窗口上画。核心是算格子尺寸:取窗口宽高的较小值,减去边距,除以 4。这样窗口拉成非正方形时,棋盘依然居中且是正方形。
void GameWidget::paintEvent(QPaintEvent *) { QPainter p(this); p.setRenderHint(QPainter::Antialiasing, true); // 圆角不开抗锯齿会有毛边 p.fillRect(rect(), QColor(0xFA, 0xF8, 0xEF)); const int margin = 12; const int cell = (qMin(width(), height()) - margin * (Board::N + 1)) / Board::N; const int total = cell * Board::N + margin * (Board::N + 1); const int offsetX = (width() - total) / 2; // 左右居中 const int offsetY = (height() - total) / 2; for (int r = 0; r < Board::N; ++r) { for (int c = 0; c < Board::N; ++c) { QRect box(offsetX + margin + c * (cell + margin), offsetY + margin + r * (cell + margin), cell, cell); int v = m_board.at(r, c); p.setPen(Qt::NoPen); p.setBrush(v ? tileColor(v) : QColor(0xCD, 0xC1, 0xB4)); p.drawRoundedRect(box, 6, 6); // 6 像素圆角,格子小的时候别给太大 if (v == 0) continue; QFont f = font(); f.setPixelSize(v < 100 ? cell / 3 : (v < 1000 ? cell / 4 : cell / 5)); f.setBold(true); p.setFont(f); p.setPen(v <= 4 ? QColor(0x77, 0x6E, 0x65) : QColor(0xF9, 0xF6, 0xF2)); p.drawText(box, Qt::AlignCenter, QString::number(v)); } } }字号的换算按位数分三档,这一步不做的话,1024 这种四位数会撑出方块。配色用一张表管理,比在switch里堆十六进制好看也好改:
| 数值 | 背景色 | 文字色 | 字号系数 |
|---|---|---|---|
| 空格 | #CDC1B4 | 不绘字 | — |
| 2 / 4 | #EEE4DA / #EDE0C8 | #776E65 | cell / 3 |
| 8 ~ 64 | #F2B179 → #F67C5F | #F9F6F2 | cell / 3 |
| 128 ~ 512 | #F65E3B → #EDCF72 | #F9F6F2 | cell / 4 |
| 1024 以上 | #EDC22E 系 | #F9F6F2 | cell / 5 |
参数说明:qMin(width(), height())保证棋盘不会被拉变形,offsetX/offsetY做居中补偿,margin同时参与总数计算和每格定位,少乘一个就对不齐。QPainter::Antialiasing只在画圆角时有用,画纯矩形时开着也无妨,这个规模不影响帧率。
4.2 keyPressEvent 与焦点策略:方向键没反应先查这两处
「界面出来了,按方向键毫无反应」是这份工程里出现频率最高的问题。原因几乎只有两个:focusPolicy没设,或者焦点被别的控件抢走了。QWidget的默认焦点策略是Qt::NoFocus,也就是说它天生不接收键盘事件,必须显式打开。
GameWidget::GameWidget(QWidget *parent) : QWidget(parent) { setFocusPolicy(Qt::StrongFocus); // 没有这行,方向键永远不会进 keyPressEvent setMinimumSize(360, 500); m_board.reset(); loadBest(); // 读最高分,见 4.4 } void GameWidget::keyPressEvent(QKeyEvent *e) { int dir = -1; switch (e->key()) { case Qt::Key_Left: dir = 0; break; case Qt::Key_Right: dir = 1; break; case Qt::Key_Up: dir = 2; break; case Qt::Key_Down: dir = 3; break; default: QWidget::keyPressEvent(e); // 其它按键交还父类,别默默吞掉 return; } if (m_animating) // 动画没播完,忽略这次输入,见 4.3 return; if (m_board.move(dir)) { // 逻辑先算完,再谈怎么显示 update(); // 触发重绘,不要手动调 paintEvent if (!m_board.canMove()) gameOver(); } }Qt::StrongFocus表示既接受 Tab 聚焦也接受点击聚焦,对这个只有一个窗口的游戏是合适的。如果以后把棋盘放进滚动区域或者加了按钮,要注意点击按钮后焦点会跑到按钮上,再按方向键就没反应——排查方法是临时在keyPressEvent开头加一行qDebug() << "key" << e->key();,能打印说明事件到了,问题在逻辑;打印不出来,问题在焦点。
另一个细节:不认识的按键一定要交还给父类,别写成空的default: break;。有些按键组合(比如 Esc 关窗口)是父类处理的,吞掉之后行为就变了。
4.3 QPropertyAnimation 的坐标换算:动画只是视觉层
滑动动画是这份工程里最容易把人带偏的部分。核心原则一句话:棋盘状态在move()返回的那一刻就已经改完了,动画只是让玩家看到变化过程。如果反过来,让动画结束再去改数据,那么快速连按两下方向键就会算出两次合并,分数翻倍,方块数字错乱——这是新手版本最典型的玄学 bug。
// 动画只改图元坐标,不碰 Board 里的任何数字 void GameWidget::animateTile(TileItem *item, const QPointF &target) { QPropertyAnimation *anim = new QPropertyAnimation(item, "pos", this); anim->setDuration(110); // 超过 150ms 手感发黏 anim->setStartValue(item->pos()); anim->setEndValue(target); anim->setEasingCurve(QEasingCurve::OutQuad); // 起步快、收尾稳 anim->start(QAbstractAnimation::DeleteWhenStopped); // 自动回收,不用手动 delete ++m_animCount; // 计数,配 m_animating 用 connect(anim, SIGNAL(finished()), this, SLOT(onAnimFinished())); }参数怎么定:setDuration(110)是踩过坑的经验值,80 以下看不出位移,150 以上连续操作会明显觉得「卡手」;QEasingCurve::OutQuad让方块起步快、落位缓,视觉上像被吸附过去,比线性自然。DeleteWhenStopped很重要,Qt4 时代很多人手动delete动画对象,结果在finished()槽里把发送者删了,直接崩。
坐标换算的公式和paintEvent里算格子位置完全一样,抽成一个QRect cellRect(int r, int c)函数,绘制和动画都调它,避免两处公式不一致导致方块落位偏移半格。m_animating这个标志在m_animCount > 0时为真,用来挡住动画期间的键盘输入——不是防作弊,是防止视觉状态和逻辑状态错位。想做得更顺滑,可以在动画期间把输入缓存成队列,播放完再执行,但新手版本挡住就够了。
4.4 分数与最高分:QSettings 落在哪、什么时候写
最高分用QSettings存最省事,不用自己管文件路径。关键是三件事:构造时给全 scope/组织名/应用名,读的时候给默认值,写完显式sync()。
void GameWidget::loadBest() { QSettings cfg(QSettings::IniFormat, QSettings::UserScope, "demo", "game2048"); // 参数顺序:格式、作用域、组织、应用 m_best = cfg.value("best", 0).toInt(); // 第二个参数是读不到时的默认值 } void GameWidget::saveBestIfNeeded() { if (m_score <= m_best) return; m_best = m_score; QSettings cfg(QSettings::IniFormat, QSettings::UserScope, "demo", "game2048"); cfg.setValue("best", m_best); cfg.sync(); // 立刻落盘,别等析构 }QSettings的四个参数版本比两参数版本可靠得多——两参数版本依赖应用名和组织名,换个启动方式就读到另一个文件里去了,现象是「最高分重启就归零」。sync()的意义是把缓存写回磁盘,游戏崩了也不丢分。排查文件到底写到哪,打印一句qDebug() << cfg.fileName();就知道了,比自己猜路径快。
写盘的时机也要注意:不要放进paintEvent。那里每帧都可能执行,等于每帧读写一次磁盘,卡顿又伤盘。放在每次move()之后判断一次,或者游戏结束时统一写,都合理。
5. 避坑与排查:编译过了却一按方向键就退出的五个现场
这一章是这份工程里最值钱的部分。前面四章的代码照着写基本能跑,但能不能改、改完会不会崩,取决于你对下面这几件事有没有概念。每条都按「现象 → 原因 → 解决」写,照着对号入座。
现象一:qmake顺利通过,make报QWidget: No such file or directory,或者No such slot。
原因是版本错配。前一半是 Qt5 环境在编译 Qt4 风格的代码:widgets在 Qt4 里属于gui模块,Qt5 拆成了独立模块,.pro里没声明就找不到,同时头文件从<QtGui>一把抓变成按类引入。后一半是信号槽的经典问题——Qt4 只能用SIGNAL()/SLOT()字符串宏,编译器不检查,槽函数名拼错、参数类型不匹配、忘了写Q_OBJECT宏,都要等到运行时才在控制台刷一行QObject::connect: No such slot。
解决:.pro里加greaterThan(QT_MAJOR_VERSION, 4): QT += widgets,头文件改成具体的类名;每个带信号槽的类第一行必须写Q_OBJECT,改完类结构之后make clean再qmake重新生成,因为 moc 生成的中间文件不会自动更新。连接失败时看控制台那行No such slot后面的完整签名,和头文件里的声明逐字对一遍,通常是const或者引用符号的差异。
现象二:窗口正常显示,按方向键完全没反应,也没有任何报错。
原因是焦点没开。QWidget默认focusPolicy是Qt::NoFocus,键盘事件根本不会派发到它身上;另一种情况是窗口里还有别的可聚焦控件(输入框、按钮、滚动区域),它们抢走了焦点。
解决:构造函数里setFocusPolicy(Qt::StrongFocus)。如果加了其他控件,处理完按钮点击后手动把焦点还回来:m_gameWidget->setFocus();。验证方法是在keyPressEvent第一行加qDebug(),能打印说明事件到了、问题在逻辑分支,打印不出来就是焦点问题,不要在逻辑里瞎找。
现象三:连按两下方向键,分数加了两次或者方块变成不该出现的数字,偶尔还会崩。
原因是逻辑和动画耦合。常见写法是在按键里启动动画,在动画的finished()槽里才更新棋盘数据,两次按键的动画重叠,数据就被改了两次。更隐蔽的一种是把m_score +=同时写在move()和finished()里。
解决:把顺序固定成「先算逻辑、再播动画」。move()返回时数据已经是最终状态,动画只负责把方块从旧位置移动到新位置。用一个int m_animCount计数,非零时直接return忽略输入。如果确实需要支持连按,就维护一个方向队列,动画结束后取队首执行——但新手版本不建议上这个复杂度。
现象四:一按右键程序直接退出,Release 版更明显,Debug 版有时只是数字错乱。
原因是取行/列时的下标越界。右移要把行倒着取,m_cells[i * N + (N - 1 - j)],写回时如果手滑写成m_cells[i * N + (N - j)],j = 0时下标正好等于N,越界一格,读到的是下一行的第一个元素,写回时就把隔壁的值覆盖了。int m_cells[16]在栈上,越界不报错,只是悄悄改坏内存。
解决:把四个方向的取/写回抽成一对函数,readLine(dir, i, line)和writeLine(dir, i, line),保证两边的索引公式是同一份代码;再给move()写单元测试,用固定棋盘跑四个方向各一次,比对结果数组。Debug 版下把m_cells前后各加一个哨兵值,越界会立刻暴露。这类问题靠肉眼审查很难发现,因为公式看起来「差不多」。
现象五:最高分重启就归零,或者分数时不时丢失。
原因是QSettings用了两参数构造,路径依赖应用名和组织名,换个启动方式(比如从 IDE 启动 vs 双击 exe)工作目录不同,读到的就是另一个文件;另一种情况是写完没sync(),进程被强杀时缓存还没落盘。
解决:用四参数构造QSettings(QSettings::IniFormat, QSettings::UserScope, "demo", "game2048"),把路径固定下来;写完调用sync();排查时打印 `cfg.fileName
本文还有配套的精品资源,点击获取