
简介这份压缩包面向具备C基础、希望借助Qt4入门GUI开发的初学者提供一套可运行的2048数字拼图小游戏工程用于练习面向对象设计、事件处理、图形界面绘制与基础游戏逻辑组织。项目围绕棋盘状态维护、数字方块合并、滑动输入响应、得分统计及结束判断展开涉及GameBoard、Tile、GameManager等类的职责划分并借助Qt4的图形视图、自定义图元与信号槽机制呈现动态效果便于理解界面层与逻辑层如何协作。包内共6个文件以2个cpp源码和1个h头文件承载主要逻辑辅以1个ui界面文件、1个pro工程文件和1个用户配置压缩包约7KB体量轻便导入后即可对照阅读、编译调试与二次修改。已有270人学习可作为课程作业、自学练手或Qt4小型项目模板帮助读者梳理从窗口搭建、输入响应到游戏流程控制的完整实现路径。1. 从机房那台老机器说起这份 Qt4 版 2048 能解决什么问题机房角落里那台老机器上还装着 Qt4 的环境课程设计要交一个能跑起来的图形界面小游戏翻了一下午也没找到一份 qmake 完就能直接弹出窗口的 2048 源码——要么缺 .pro要么依赖一堆装不上的库。这份 Qt4 版 2048 小游戏就是干这个的纯 C 加 QWidget 自绘一个 .pro、三个类、不到十份文件不依赖任何第三方库、不连网络、不碰数据库用 Qt Creator 打开或者命令行 qmake 加 make十几秒就能看到 4x4 棋盘弹出来。它适合三类人C 语法刚过一遍、想拿第一个 Qt 工程练手的新手需要交课程设计或实训作品的在校学生以及想把 Qt 的事件处理、信号槽、QPainter 自绘这三件套一次性看明白的开发者。代码量不大但骨架完整——键盘事件、棋盘状态、分数统计、终局判定、重新开局都在属于那种拆完就能照着写自己版本的模板工程。2. 工程结构与技术选型qmake、三个类和一个 4x4 二维数组2.1 目录清单一个 .pro、三个类不到十份文件解压之后目录是扁平的没有 src/include 那种多层嵌套这对初学者其实是好事——不用先花半小时搞清楚哪个文件在哪。核心文件大致是下面这些我按要不要动它分了一列方便你判断改造时该从哪儿下手。文件作用二次开发时是否常改Game2048.proqmake 工程描述声明模块、源文件、头文件加文件、换模块时要改main.cpp程序入口创建 QApplication 和主窗口基本不动mainwindow.h / .cpp / .ui外壳窗口分数、最高分、重开按钮的布局改 UI 时改gameboard.h / .cpp棋盘控件状态数组、移动合并、绘制、按键绝大部分逻辑都在这资源文件图标等窗口图标、按钮图标可选真正承载业务逻辑的其实只有 gameboard.cpp 一个文件。我第一次拆这类小工程的习惯是先把 .pro 读一遍确认模块依赖和文件列表再直奔逻辑最重的那个 .cpp——因为界面外壳通常十分钟就能看懂逻辑才是需要琢磨的部分。这份代码也是同样的分布画格子的和算数字的都在 GameBoard 里MainWindow 更像个容器。提示如果你的解压目录里还带了 build 文件夹或者 .user 文件那是原作者用 Qt Creator 打开后留下的产物删掉不影响编译反而能避免旧路径干扰。2.2 .pro 与 main.cppQt4 工程的骨架长什么样Qt4 时代的工程文件比现在简洁但也埋了一个后来让无数人踩坑的坑——Qt5 把 widgets 模块从 gui 里拆出去了。所以一份既能在 Qt4 上编译、又不想在 Qt5 上直接报错的 .pro通常会写一个版本判断。# Game2048.pro —— qmake 工程描述 QT core gui greaterThan(QT_MAJOR_VERSION, 4): QT widgets # Qt5 起 widgets 独立成模块 TARGET Game2048 TEMPLATE app SOURCES main.cpp \ mainwindow.cpp \ gameboard.cpp HEADERS mainwindow.h \ gameboard.h FORMS mainwindow.ui # Qt4 老工程常见的一行关掉会拖慢编译的冗余警告 QMAKE_CXXFLAGS -Wall这段配置里真正需要理解的就三行。QT core gui声明用到的 Qt 模块core 是容器、字符串、信号槽的基础gui 是绘图和窗口系统greaterThan(QT_MAJOR_VERSION, 4): QT widgets是条件语句只有主版本号大于 4 时才追加 widgets 模块这样同一份 .pro 在 Qt4.8 和 Qt5.x 下都能过TEMPLATE app表示产出的是一个可执行程序而不是库。SOURCES和HEADERS决定了哪些文件参与编译后面你自己加新类忘了往这里补一行编译器就会甩给你一句 undefined reference。入口文件几乎是模板化的但有两点值得注意。// main.cpp #include QApplication #include QTextCodec #include mainwindow.h int main(int argc, char *argv[]) { QApplication app(argc, argv); #if QT_VERSION 0x050000 // Qt4 下源码若以 UTF-8 保存需显式告诉 QObject::tr 用哪种编码 QTextCodec::setCodecForTr(QTextCodec::codecForName(UTF-8)); QTextCodec::setCodecForCStrings(QTextCodec::codecForName(UTF-8)); #endif MainWindow w; w.show(); return app.exec(); }QT_VERSION是编译期宏0x050000对应 5.0.0这段条件编译的意思是只有跑在 Qt4 上才执行下面两行。setCodecForTr影响tr(中文)的解析编码setCodecForCStrings影响QString从const char*隐式构造时的编码Qt4 默认按 Latin-1 处理源码里写的中文不设置就会变成问号或乱码。Qt5 之后这两个函数被移除默认按 UTF-8 处理所以必须用宏包起来否则在 Qt5 上直接编译失败。app.exec()进入事件循环窗口才会真正响应点击和按键这一行之前的代码都是一次性执行之后的逻辑全靠信号槽和事件回调驱动。2.3 技术选型为什么用 QWidget 自绘而不是 16 个 QLabel2048 的棋盘是 4x4 十六个格子最直觉的做法是往 QGridLayout 里塞十六个 QLabel改数字就 setText改颜色就 setStyleSheet。这个方案能跑但作者选了另一条路整个棋盘是一个 QWidget 子类所有格子都在paintEvent里用 QPainter 画出来。两条路的差别我列了个表。方案优点缺点QGridLayout 16 个 QLabel上手快改文字改样式都用现成 API窗口缩放时格子比例难对齐想做滑动动画得逐个控件挪十六个对象管理成本高单 QWidget paintEvent 自绘缩放、圆角、配色、字号全部自己说了算动画只改一个坐标状态只有一份数组需要理解坐标计算和重绘时机初学门槛稍高对一个初学定位的项目来说选自绘其实是对的。2048 的核心难点从来不是控件摆放而是那个压缩—合并—补零的算法用 QLabel 反而会把注意力引到样式表上。自绘方案还有个隐性好处棋盘状态只有int m_grid[4][4]一份数据源界面永远是它的投影不会出现数组改了但某个 label 忘了刷新的经典不一致问题。代价是要自己算坐标。核心思路很朴素取窗口宽高的较小值当正方形边长减去五份间距剩下的除以四得到单格尺寸然后用行列下标乘加出每个格子的矩形区域。这套算法在后面的绘制章节会展开。2.4 棋盘状态怎么存int grid[4][4] 与一回合只合并一次数据结构这块没什么花活就是一个固定大小的二维整型数组0 表示空其它值就是 2、4、8 这种二的幂。分数单独一个 int。真正需要说清的是类接口的划分——哪些是 public、哪些是 private、哪些用信号发出去。// gameboard.h class GameBoard : public QWidget { Q_OBJECT public: enum Direction { Left, Right, Up, Down }; explicit GameBoard(QWidget *parent 0); void restart(); int score() const { return m_score; } signals: void scoreChanged(int score); // 分数变了通知外壳更新标签 void gameOver(); // 无法再移动时通知外壳弹提示 protected: void paintEvent(QPaintEvent *event); void keyPressEvent(QKeyEvent *event); private: bool move(Direction dir); // 返回是否真的发生了位移 void spawnTile(); // 在随机空位生成 2 或 4 bool canMove() const; // 还有没有可行的一步 QVectorint readLine(int index, Direction dir) const; void writeLine(int index, Direction dir, const QVectorint line); int m_grid[4][4]; int m_score; };Q_OBJECT宏不能省它让 moc 工具为这个类生成元对象代码信号槽才能工作漏了它编译器会报 undefined reference to vtable for GameBoard这个报错信息对新手极不友好属于典型的找不到北。move返回 bool 而不是 void因为后面要靠这个返回值决定要不要生成新块——如果玩家按了左键但棋盘纹丝不动就不该冒出新数字否则等于白送一步。这里有个容易被忽略的规则同一个方块在一回合内只能合并一次。比如一行是2 2 2 2向左的结果应该是4 4 0 0而不是8 0 0 04 4 4 0向左应该是8 4 0 0。如果边遍历边在原数组上改很容易让刚合并出来的 8 又跟下一个 4 撞上。常见的处理办法是先把这一行的非零值抽到一个临时 QVector 里在临时容器上做合并做完再补零写回这样天然满足一回合一次的约束也不用额外的 bool 标记数组。3. 核心逻辑拆解一次方向键背后的压缩、合并与计分3.1 按键入口keyPressEvent 与焦点问题Qt 的键盘事件只发给当前拥有焦点的控件。很多人第一次跑起来发现窗口是出来了但按方向键没反应八成就是焦点没设对——焦点可能停在重开按钮上方向键被按钮吃掉了。GameBoard 在构造函数里必须声明自己接受焦点。// gameboard.cpp 构造函数 GameBoard::GameBoard(QWidget *parent) : QWidget(parent), m_score(0) { // 关键不设这个点过按钮之后方向键就再也进不来了 setFocusPolicy(Qt::StrongFocus); setMinimumSize(320, 320); restart(); }Qt::StrongFocus表示既接受 Tab 键切换焦点、也接受鼠标点击获得焦点如果写成Qt::NoFocus这个控件永远不会响应键盘Qt::ClickFocus则只有点击后才响应窗口刚打开时按键是无效的。setMinimumSize给一个下限防止窗口被拖得太小导致格子尺寸算成负数。事件处理函数本身很薄只做分发和副作用控制。void GameBoard::keyPressEvent(QKeyEvent *event) { bool moved false; switch (event-key()) { case Qt::Key_Left: moved move(Left); break; case Qt::Key_Right: moved move(Right); break; case Qt::Key_Up: moved move(Up); break; case Qt::Key_Down: moved move(Down); break; default: QWidget::keyPressEvent(event); // 不认识的键交还父类别吞掉 return; } if (moved) { spawnTile(); // 只有真的动了才生成新块 emit scoreChanged(m_score); // 通知外壳刷新分数 if (!canMove()) { emit gameOver(); } } }event-key()返回的是键盘码方向键是Qt::Key_Left这一组枚举和字符键的Qt::Key_A在同一套体系里。default分支里把事件交还给父类是个好习惯吞掉所有按键会让快捷键、Esc 关闭这类系统行为失效。emit是 Qt 的关键字本质是调用信号的 moc 生成函数同步执行所有连上来的槽所以槽函数里不要做耗时操作否则界面会卡住。注意有些实现会在 keyPressEvent 里对未处理的按键直接return而不调用父类窗口的 Esc 关闭、Tab 切换就都失灵了。这类小细节在演示时不明显真用起来很别扭。3.2 单行压缩合并三趟循环解决一个方向2048 里最核心的算法就是把一行往一个方向推。写完四个方向各一套逻辑是新手最容易掉进去的坑——四份几乎一样的代码改一个 bug 要改四遍。比较优雅的做法是把所有方向都折叠成向左只写一份逻辑输入输出时做下标映射。先看这份唯一的逻辑。// 把一行长度 4向左压缩并合并gain 带回本行得分 void GameBoard::collapse(QVectorint line, int gain) { QVectorint packed; packed.reserve(4); // 第一趟把非零块按顺序挤到前面 for (int i 0; i line.size(); i) { if (line[i] ! 0) packed.append(line[i]); } // 第二趟相邻同值合并一回合每个块只合并一次 for (int i 0; i 1 packed.size(); ) { if (packed[i] packed[i 1]) { packed[i] * 2; gain packed[i]; // 合并出来的值就是得分 packed.remove(i 1); // 后面的补位长度减一 i; // 跳过刚合并出的新块避免连环合并 } else { i; } } // 第三趟右侧补零到 4 格 while (packed.size() 4) packed.append(0); line packed; }第一趟循环的边界是line.size()而不是硬编码的 4这样以后想改成 5x5、6x6 的棋盘这个函数一行都不用动。第二趟的i位置很关键合并成功之后必须手动跳过新生成的块否则2 2 4会先合成4 4再合成8得到错误结果而合并失败时正常前进所以写成了 for 循环里空着的第三个表达式加手动递增。第三趟补零用的是 while保证无论前面剩几个都能补满。gain是引用参数把这一行产生的分数带出去由上层累加到总分。之所以不在这里直接改成员变量 m_score是为了让这个函数保持纯逻辑、可单独测试——把 QVector 传进去比对输出就行不需要构造整个窗口。3.3 方向复用readLine 和 writeLine 的下标映射有了唯一的向左逻辑四个方向的差别就只剩怎么把棋盘的一行读出来和怎么写回去。左和右读的是同一行只是顺序反过来上和下读的是同一列同理。QVectorint GameBoard::readLine(int index, Direction dir) const { QVectorint line(4); for (int k 0; k 4; k) { switch (dir) { case Left: line[k] m_grid[index][k]; break; case Right: line[k] m_grid[index][3 - k]; break; // 反向读等价于向左推 case Up: line[k] m_grid[k][index]; break; // 整列读成一行 case Down: line[k] m_grid[3 - k][index]; break; } } return line; } void GameBoard::writeLine(int index, Direction dir, const QVectorint line) { for (int k 0; k 4; k) { switch (dir) { case Left: m_grid[index][k] line[k]; break; case Right: m_grid[index][3 - k] line[k]; break; case Up: m_grid[k][index] line[k]; break; case Down: m_grid[3 - k][index] line[k]; break; } } }index在左右方向上是行号在上下方向上是列号同一个参数两种含义靠 dir 区分。3 - k这个反向下标是整个技巧的核心对右方向来说最右边的格子才是第一位反向读进来之后按向左的规则处理再反向写回去视觉上就是向右推。上下同理只是把行换成列。有了这两个函数move 就非常短了。bool GameBoard::move(Direction dir) { bool changed false; for (int i 0; i 4; i) { QVectorint line readLine(i, dir); const QVectorint before line; int gain 0; collapse(line, gain); if (line ! before) { // QVector 重载了 !逐元素比较 writeLine(i, dir, line); m_score gain; changed true; } } return changed; }before存的是处理前的副本用line ! before判断这一行有没有变化只有变了才写回并加分。这个判断同时解决了两个问题避免无意义的写操作以及给上层提供这一步是否有效的依据。注意gain在没变化时也是 0所以即使用它加分也不会出错但放在 if 里面语义更清楚。3.4 新块生成、计分与终局判定每次有效移动之后要冒出一个新数字。Qt4 环境里用qrand()就够了不必引入 C11 的随机数库能少一个编译选项。void GameBoard::spawnTile() { QVectorQPoint empty; for (int r 0; r 4; r) for (int c 0; c 4; c) if (m_grid[r][c] 0) empty.append(QPoint(r, c)); if (empty.isEmpty()) return; // 满了就直接返回交给 canMove 判负 const QPoint cell empty.at(qrand() % empty.size()); // 90% 出 210% 出 4和原版手感接近 m_grid[cell.x()][cell.y()] (qrand() % 10 0) ? 4 : 2; update(); // 请求重绘不是立即重绘 }先把所有空位收集到一个 QVector 里再随机挑一个这是最不容易出错的写法。另一种常见做法是随机行列然后循环重试直到撞上空位当棋盘接近满的时候会反复空转极端情况下退化成死循环。QPoint在这里只当坐标容器用x 存行、y 存列别被名字误导。update()是把重绘请求丢进事件循环会合并多次请求比repaint()的同步立即重绘高效得多绘制函数里千万不要再调用 repaint 造成递归。终局判定的关键是棋盘满了不一定就输了只要还有相邻同值就能继续。bool GameBoard::canMove() const { for (int r 0; r 4; r) { for (int c 0; c 4; c) { if (m_grid[r][c] 0) return true; // 有空位 if (c 1 4 m_grid[r][c] m_grid[r][c 1]) return true; // 右邻同值 if (r 1 4 m_grid[r][c] m_grid[r 1][c]) return true; // 下邻同值 } } return false; }这段是 O(16) 的常量级扫描每个格子只看右边和下边两个邻居就能覆盖全部相邻关系不会重复也不会遗漏。边界判断c 1 4必须写在取值之前C 的短路求值保证了不会越界访问但如果把两个条件顺序写反就是标准的数组越界运气好读到脏数据运气差直接崩。4. 绘制与信号槽paintEvent 里把数字画成色块的细节4.1 坐标系与格子尺寸一次算对缩放不崩paintEvent 是自绘控件的核心每次需要重绘时被调用。这里最容易翻车的是尺寸计算——写死像素值的话窗口一拉大就走形。void GameBoard::paintEvent(QPaintEvent *event) { Q_UNUSED(event); QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, true); painter.fillRect(rect(), QColor(0xBB, 0xAD, 0xA0)); // 底板色 const int gap qMax(6, width() / 60); // 间距随宽度自适应但不小于 6px const int side qMin(width(), height()) - gap * 5; // 4 格 5 条缝 const int cell side / 4; for (int r 0; r 4; r) { for (int c 0; c 4; c) { const QRect box(gap c * (cell gap), gap r * (cell gap), cell, cell); drawTile(painter, box, m_grid[r][c]); } } }qMin(width(), height())保证棋盘始终是正方形窗口被拉成长条时也不会变形多出来的空间留给底板色。gap * 5是因为四格之间有五条缝——左边一条、中间三条、右边一条这个数字算错就会出现右边或下边缺一块的视觉效果属于典型的看起来像 bug 其实是算术错。Q_UNUSED(event)告诉编译器这个参数故意不用消掉 -Wall 下的未使用警告。抗锯齿必须开否则圆角格子边缘全是锯齿观感差得远。4.2 颜色映射与字号自适应数字变大之后颜色要跟着变这是 2048 的视觉传统。用一张静态映射表最省事比一长串 if-else 好维护。static QColor tileColor(int value) { switch (value) { case 0: return QColor(0xCD, 0xC1, 0xB4); // 空格 case 2: return QColor(0xEE, 0xE4, 0xDA); case 4: return QColor(0xED, 0xE0, 0xC8); case 8: return QColor(0xF2, 0xB1, 0x79); case 16: return QColor(0xF5, 0x95, 0x63); case 32: return QColor(0xF6, 0x7C, 0x5F); case 64: return QColor(0xF6, 0x5E, 0x3B); case 128: return QColor(0xED, 0xCF, 0x72); case 256: return QColor(0xED, 0xCC, 0x61); case 512: return QColor(0xED, 0xC8, 0x50); case 1024: return QColor(0xED, 0xC5, 0x3F); case 2048: return QColor(0xED, 0xC2, 0x2E); default: return QColor(0x3C, 0x3A, 0x32); // 4096 及以上用深色 } } void GameBoard::drawTile(QPainter p, const QRect box, int value) { p.setPen(Qt::NoPen); p.setBrush(tileColor(value)); p.drawRoundedRect(box, box.width() / 12, box.width() / 12); if (value 0) return; // 空格不写字 // 位数越多字越小四位数的 2048 不能撑破格子 QFont f font(); const double ratio (value 100) ? 0.42 : (value 1000) ? 0.34 : 0.26; f.setPixelSize(qMax(10, static_castint(box.height() * ratio))); f.setBold(true); p.setFont(f); p.setPen(value 4 ? QColor(0x77, 0x6E, 0x65) : QColor(0xF9, 0xF6, 0xF2)); p.drawText(box, Qt::AlignCenter, QString::number(value)); }drawRoundedRect的第三、四个参数是圆角半径用格子宽度的十二分之一作为基准缩放时圆角比例保持恒定。字号用比例算而不是固定像素窗口拉大时数字跟着变大这是自绘方案相对 QLabel 的优势所在。文字颜色在 2 和 4 这种浅底上用深灰其余用近白色对比度才够——早期版本我见过全用深色的1024 那块几乎看不清。p.setPen(Qt::NoPen)放在画矩形之前是为了去掉边框线如果忘了这一句每个格子外面会多一圈默认的黑色描边看起来像格子线太粗。4.3 分数更新与重开两种信号槽写法的取舍外层窗口负责显示分数和重开按钮和棋盘之间通过信号槽解耦。Qt4 时代只有一种写法就是 SINSL/SLOT 字符串宏。MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent), ui(new Ui::MainWindow) { ui-setupUi(this); // 旧式写法字符串在运行时匹配拼错了编译期不报错 connect(ui-board, SIGNAL(scoreChanged(int)), this, SLOT(onScoreChanged(int))); connect(ui-board, SIGNAL(gameOver()), this, SLOT(onGameOver())); connect(ui-restartButton, SIGNAL(clicked()), this, SLOT(onRestartClicked())); ui-board-setFocus(); // 启动就把焦点给棋盘省得用户先点一下 } void MainWindow::onScoreChanged(int score) { ui-scoreLabel-setText(QString::number(score)); if (score m_best) { // 最高分可以顺手写进 QSettings m_best score; ui-bestLabel-setText(QString::number(m_best)); } } void MainWindow::onRestartClicked() { ui-board-restart(); ui-board-setFocus(); // 点完按钮焦点跑到按钮上了必须抢回来 }SIGNAL和SLOT两个宏把函数签名转成字符串connect 在运行时查元对象表做匹配。好处是 Qt4 完全支持坏处是拼写错误、参数类型不匹配都只在运行期打印一行QObject::connect: No such slot的警告程序照常启动但功能静默失效——这是新手最常卡住的地方之一。Qt5 引入的编译期连接connect(ui-board, GameBoard::scoreChanged, ...)能把这个错误提前到编译期但为了兼容 Qt4这份代码保持了旧式写法。onRestartClicked里那句setFocus()是血泪经验。用户点完重开按钮焦点会留在按钮上这时候按方向键毫无反应看起来就像重开之后游戏卡死了。构造函数里设一次、重开后补一次才算完整。5. 编译跑通与踩坑排查从 .pro 到能按方向键的完整链条5.1 三条命令跑起来或者直接用 Qt Creator用 Qt Creator 的话最省事打开Game2048.pro选好 KitQt4 就选 Qt4 的 qmake 路径按 CtrlR 即可。想更清楚地看到编译过程用命令行。# 进入源码目录 cd Game2048 # 生成 Makefile注意用 Qt4 的 qmake 全路径避免和系统里的 Qt5 混用 /opt/qt4/bin/qmake Game2048.pro # 编译-j4 表示四线程并行 make -j4 # 运行 ./Game2048第一行的 qmake 路径一定要显式指定。机器上同时装了 Qt4 和 Qt5 是很常见的情况PATH 里排前面的是哪个就用哪个用错版本会冒出一堆看不懂的报错比如 widgets 找不到、QTextCodec::setCodecForTr未声明。第二个要注意的是改了 .pro 之后必须重新跑一遍 qmake只 make 是不会重新读工程文件的新增的源文件根本不会参与编译。5.2 五条踩坑记录现象、原因、解决下面这几条是我在这个工程上真实遇到过的按出现频率排。一、编译报QMainWindow: No such file or directory现象包含#include QMainWindow的那一行直接报错头文件找不到。 原因Qt5 把 widgets 模块从 gui 里拆了出去如果 .pro 里只写了QT core guiQt5 环境下就没有 widgets 的头文件搜索路径。 解决在 .pro 里补上greaterThan(QT_MAJOR_VERSION, 4): QT widgets然后重新执行 qmake 再 make。只在 Qt4 上跑的话这一行加了也无害。二、窗口出来了按方向键毫无反应现象界面正常显示鼠标点击、按钮都能用就是方向键没任何效果。 原因焦点不在 GameBoard 上键盘事件被按钮或别的控件接走了或者构造函数里漏了setFocusPolicy(Qt::StrongFocus)。 解决构造函数加setFocusPolicy(Qt::StrongFocus)主窗口初始化末尾加ui-board-setFocus()重开按钮的槽函数里再补一次。排查时可以在 keyPressEvent 第一行加一句 qDebug 输出确认事件到底有没有进来。三、分数加了两次或者一步消掉四个块现象一次移动之后分数比预期多一倍2 2 2 2向左直接变成8而不是4 4或者4 4 4 0变成了8丢了一个。 原因在原数组上边遍历边修改刚合并出来的新块又被参与了下一次合并。 解决统一改成先抽到临时 QVector、在临时容器上合并、再补零写回的三趟写法合并成功后务必手动跳过新块i的位置别放错。四、每次启动开局的两块位置一模一样现象关掉重开新游戏的初始布局永远是同一套。 原因Qt4 的qrand()默认种子是固定值 1不播种就产生固定序列。 解决在main.cpp里加qsrand(QTime(0, 0, 0).secsTo(QTime::currentTime()))用当前时间做种子。这一行放在 QApplication 构造之后、创建窗口之前。五、控制台打印QObject::connect: No such slot MainWindow::onScoreChanged(int)现象程序不崩但分数标签永远是 0终局提示也不弹。 原因旧式 connect 靠字符串匹配槽函数忘了加slots声明、参数类型写成int和qint32不一致、或者函数名拼错都只在运行时输出这一行警告。 解决检查头文件里槽函数是否放在private slots:下面、签名是否和信号完全一致。养成看应用程序输出面板的习惯这类问题在 GUI 上完全没有痕迹。5.3 Qt4 到 Qt5 的兼容写法如果你手上是 Qt5 环境除了 .pro 里那行判断还有几处要注意。QTextCodec::setCodecForTr和setCodecForCStrings在 Qt5 已移除必须用QT_VERSION宏包起来qrand()在 5.15 被标记废弃、Qt6 里彻底删除想兼容更远的版本可以用QRandomGenerator但为了这份初学代码的简洁保留 qrand 也完全够用QString::number、QPainter、QVector这些主力 API 在两边是一致的不用改。真正麻烦的是信号槽的编译期检查Qt5 下可以逐步把 connect 换成函数指针写法改一个测一个别一次全换。6. 二次开发加撤销、改棋盘尺寸和存档的改造路径这份代码最容易上手的三个改造方向按难度从低到高排存档、撤销、变尺寸。存档最简单也不破坏任何现有结构。开局和每次有效移动后把m_grid的十六个整数和m_score塞进 QSettings 就行重启时读回来。要注意存的是一个扁平化的列表读的时候校验长度是不是 16长度不对就当作新局处理别硬塞进去导致越界。撤销是性价比最高的一个功能也是最能体现数据结构选对了省多少事的地方。因为棋盘状态就是一个 4x4 的数组历史记录直接用QStackQVectorint 存扁平快照就够了每次有效移动前压栈撤销时弹栈回填。// 每次有效移动前保存快照 void GameBoard::pushHistory() { QVectorint snap; snap.reserve(16); for (int r 0; r 4; r) for (int c 0; c 4; c) snap.append(m_grid[r][c]); m_history.push(snap); m_historyScore.push(m_score); // 分数要跟着一起回滚 if (m_history.size() 20) // 只留最近 20 步防止无限增长 m_history.remove(0); }这里有个细节QVectorint放进QStack时 中间那个空格在 C11 之前是必须的否则会被当成右移运算符老编译器直接报语法错。另外分数必须和棋盘一起回滚只回滚棋盘不回滚分数玩家撤销几次就能刷出天文数字——这个漏洞我在别人的作业里见过不止一次。改棋盘尺寸是更能练手的改造但要把所有硬编码的 4 换成常量。好消息是前面写的collapse和canMove里那些循环本来就是按size()走的主要改的是readLine、writeLine里的3 - k、spawnTile的遍历边界以及绘制里的side / 4和gap * 5格数变了缝隙数也得跟着变。建议把4抽成static const int kSize 4;然后全局搜索替换每改一处编译一次。再往上就是给方块加滑动动画用 QPropertyAnimation 或者自己维护一个 0 到 1 的进度变量在 paintEvent 里插值。这一步会牵出一个新问题动画期间不能再接收按键否则状态会错乱需要一个m_animating标志位挡住输入。我踩过最深的一个坑是当年觉得这个项目太小了不用版本管理直接在一份代码上改尺寸改崩了想退回原来那版发现早就覆盖没了。从那以后我每次动手改别人的小工程之前都强制先做一次完整备份、把原始压缩包单独留一份再开一个新的目录改——小项目反而更容易因为反正就几百行而翻车。希望这份拆解能帮你少走几步弯路。本文还有配套的精品资源点击获取