资讯详情

Qt4版2048小游戏C++源码解析:qmake、信号槽与绘制交互

📅 2026/10/29 14:21:49 | 华诺云谱 👁 阅读
Qt4版2048小游戏C++源码解析:qmake、信号槽与绘制交互
简介这份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 标准默认不一定开 C11默认开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 的工程文件看着简单但它决定了编译器去哪找头文件、链接哪些库、开不开 C11。新手拿到压缩包直接双击.proIDE 会自动生成一套默认配置然后编译失败原因往往就是默认配置里少了东西。我一般不看 IDE 生成的直接手写一份短但可控# game2048.pro TEMPLATE app # 生成可执行程序不是库 TARGET game2048 # 产物名字Windows 下会补 .exe # Qt4 的 widgets 在 gui 模块里Qt5 拆成了独立模块这一行做兼容 greaterThan(QT_MAJOR_VERSION, 4): QT widgets QT core gui CONFIG c11 # Qt4 时代默认不开 C11这行必须有 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 c11在 Qt4 里尤其关键因为 Qt4 时代编译器默认标准还停在 C98代码里只要出现nullptr或者范围 for 就会报一串看不懂的错。QMAKE_CXXFLAGS -Wall -Wextra这行在新手眼里纯属自找麻烦——编译时多出一堆警告。但 2048 这类代码里最常见的崩溃就是数组下标越界而越界往往先以「有符号与无符号整数比较」的警告形式出现。把警告当错误看是这份工程里最划算的一个习惯。生成的 Makefile 用两条命令就够# 在 .pro 所在目录执行别跑到 build 子目录里去 qmake game2048.pro # 生成 MakefileQt4 环境下命令就叫 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 个方块画出来另一种是上QGraphicsViewQGraphicsScene每个方块是一个QGraphicsItem。这份新手工程走的是第一条路理由很实在代码量小、断点好打、不需要理解 item/scene/view 三层结构。维度QWidget paintEventQGraphicsView 场景入门成本低一个类就能跑高要先理解三套坐标系动画支持自己算坐标配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 落到下标 0dst变 1第二个 2 和line[0]相等且未合并合并成 4lastMerged记 0第三个 2 想和line[0]已是 4比不等落到下标 1dst变 2第四个 2 和line[1]相等且未合并合并成 4结果4 4 0 0。再看4 2 24 落位dst变 1第一个 2 落到下标 1dst变 2第二个 2 和line[1]相等合并成 4结果4 4 0不是8——这正是lastMerged在起作用。参数说明len传行长度4line是栈上的临时数组函数直接改它调用方负责写回。把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 - jj 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#776E65cell / 38 ~ 64#F2B179 → #F67C5F#F9F6F2cell / 3128 ~ 512#F65E3B → #EDCF72#F9F6F2cell / 41024 以上#EDC22E 系#F9F6F2cell / 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本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑