简介:这是基于C++与Qt4开发的2048小游戏入门项目,源码结构紧凑,适合刚接触面向对象编程与GUI开发的初学者学习和参考。压缩包共6个文件,包含主程序与界面实现(cpp/h)、Qt工程文件(pro)及可视化界面设计文件(ui),整体仅7KB,便于快速下载与剖析。项目通过GameBoard、Tile、GameManager三个核心类分层实现棋盘状态维护、方块合并与游戏流程控制,清晰演示了C++封装、继承以及Qt信号与槽机制、QGraphicsView绘图等核心用法,代码量不大但逻辑完整,方便逐行研读与二次扩展。已有266人学习,阅读源码可直观理解经典2048游戏的完整逻辑闭环与Qt4界面布局方式,是动手实践桌面小程序开发的轻量级示例。
1. 学完C++语法后的第一份完整项目:2048qt小游戏C++初学.zip能给你什么
刚学完指针和类,在控制台里敲了两个月练习题,最大的困惑往往是:语法都会,怎么把它们攒成一个能跟别人展示的东西?这份以 2048qt小游戏C++初学 命名的压缩包,就是很多人跨过这道坎的第一站。它把 C++ 的面向对象、数组逻辑、事件响应和 Qt 界面串在一个能玩的目标上:4x4 棋盘、键盘方向键滑动、相同数字合并、随机生成 2 和 4,直到拼出 2048。
拿这个包当学习材料,重点不在“玩”,而在“拆”:棋盘数据怎么存、一次滑动怎么算、界面怎么跟着数据刷新。这也是新手最容易错判难度的地方——界面反而是简单的,真正考验逻辑的是那套滑动合并算法。本笔记不绕弯子,直接用一套典型可复现的工程思路,带你把它跑起来、读明白、改出自己的版本。
2. 先把项目跑起来:Qt环境准备、pro工程文件与zip里的代码分布
2.1 环境选型:用哪个Qt分支、编译器怎么配最省事
拿到zip先别急着双击运行,除非包里自带编译好的exe。一般这种初学向压缩包给的是源码,需要你自己准备 Qt 环境。常见的选择是 Qt 5 的 LTS 分支或 Qt 6 系列,配合 MinGW 64 位编译器搭建。原因是 qmake 工程(也就是 .pro 文件)对初学者最友好,配置就那么几行,不太会在构建系统上翻车。
编译器方面,MinGW 64 位通常比 Windows 原生编译器省心,尤其是源码里带中文注释或中文提示时。很多初学者在这类包上用 Windows 原生编译器编译,会遇到源文件保存编码与编译器默认编码不一致导致的乱码问题,换 MinGW 后基本就消停了。版本选择上,Qt 5 的 LTS 分支文档齐全、社区问答多,适合照着重现;Qt 6 系列新,但初学项目用到的 API 差异并不大。
| 环境组合 | 适合程度 | 理由 |
|---|---|---|
| Qt 5 LTS + MinGW 64 位 | 最推荐 | 坑少、资料全、与初学代码匹配度高 |
| Qt 6 系列 + MinGW 64 位 | 可用 | API 略有变化,遇到问题答案要按 Qt 6 搜 |
| Qt 5 + Windows 原生编译器 | 慎用 | 编码与调试器配置对新手不友好 |
我一般建议初学阶段固定一套环境,别今天 Qt5 明天 Qt6、编译器来回切换,否则出了问题很难判断是代码问题还是环境问题。选定了就尽量只在这套环境下跑,等摸熟了再加第二套。
2.2 解压之后先认文件:一份典型2048工程的五个组成部分
解压前先确认路径没有中文名和空格,比如放到D:\Projects\2048,而不是桌面\新 文件夹(2)。路径里的中文和空格在 qmake 阶段偶尔会引发奇怪问题,这是第一个值得提前避开的坑。
打开zip后,一份典型的初学版2048 Qt工程,文件结构主角不是 ui 文件,而是自定义类。一个负责任的教学工程通常拆成五个部分:入口文件、界面窗口类、游戏数据类、工程描述文件、说明文档。界面窗口类负责 QWidget 和键盘事件;游戏数据类负责棋盘和滑动算法;两者分开是这种小项目最值得学习的工程习惯。
// 一份典型的 qmake 工程描述文件:2048.pro QT += core gui greaterThan(QT_MAJOR_VERSION, 4): QT += widgets TARGET = 2048 TEMPLATE = app CONFIG += c++11 SOURCES += \ main.cpp \ mainwindow.cpp \ board.cpp HEADERS += \ mainwindow.h \ board.h这个文件的关键点:TARGET决定了最终生成的 exe 名字,改成2048后运行产物就会叫2048.exe;TEMPLATE = app表示生成可执行程序而不是库;SOURCES和HEADERS列出参与编译的源文件与头文件。注意如果后来手动新增了源代码文件,必须回到这里登记,否则会出现链接不到函数的报错,这一点在避坑章节会展开讲。
main.cpp往往只有几行:创建 QApplication,创建主窗口,显示,进入事件循环。board.h和board.cpp是逻辑核心,跟 Qt 的界面类解耦,这样可以用命令行先验证算法对不对,再接上界面。README 或注释里通常有操作说明,比如按 R 重开、方向键移动等,每个包规矩不一样,以代码里实际写的为准。
2.3 在Qt Creator里跑通的最小操作序列
最常见的启动方式是 Qt Creator 直接打开工程文件。操作路径固定:启动 Qt Creator,选择“打开项目”,定位到你解压后的.pro文件,然后耐心等它完成配置。如果弹出选择套件的窗口,优先选 MinGW 64 位那一项,点构建再点运行,看到棋盘窗口弹出来就算跑通了。
# 不习惯图形界面也可以用命令行构建 mkdir build && cd build qmake ../2048.pro mingw32-make两条命令的含义:qmake根据.pro生成 Makefile,mingw32-make按 Makefile 的规则调用编译器逐个编译源文件并链接。逻辑上等价于 Qt Creator 里点“构建”,只是少了图形反馈。初学阶段我建议在 Qt Creator 里操作,出错后能看到红色的编译输出并跳转到对应代码行,比看终端信息直观得多。
首次构建如果报错,先看输出窗口最上面的几行,不要翻到最后。真正的错误信息通常紧跟“error:”字样,后面是文件路径和行号。第一次跑通之后,可以尝试改一个数字、保存、重新构建,观察变化,这条“改一处,跑一次”的节奏能帮你建立对编译过程的直觉。
3. 2048的核心算法:滑动合并、随机生成与终局判断的完整实现
3.1 数据层怎么设计:为什么把棋盘和界面拆开
很多初学版本把棋盘数组直接放在主窗口类里,写起来快,但界面代码和逻辑代码搅在一起,改一个手势就要动界面。稳妥的设计是把棋盘抽象成独立的游戏类,例如Board,内部维护一个 4x4 的整型数组,外部只暴露移动、读取格值、查询分数等方法。
// board.h —— 数据与规则层,不依赖 Qt 界面 #ifndef BOARD_H #define BOARD_H class Board { public: Board(); bool move(int direction); // 0=左, 1=上, 2=右, 3=下 int cell(int r, int c) const; // 读某格的值,0 表示空 int score() const { return m_score; } bool canMove() const; // 还有空位或可合并相邻块 bool reached2048() const; private: int m_data[4][4]; int m_score = 0; bool mergeLine(int line[4]); // 单行合并核心 void generateTile(); // 生成 2 或 4 }; #endif // BOARD_Hcell()用const修饰,表示它只读不写棋盘;move()返回布尔值,表示这次移动是否改变了棋盘,返回值为 false 时界面就不该刷新,也不该生成新块。这样的接口设计让界面层调用起来非常干净:一个移动、一个读数、一个分数,三个查询接口,足够支撑整个界面逻辑。
数据层独立出来后,验证算法不一定要启动界面。你可以临时在main.cpp里手动构造一组棋盘,调用move()后打印cell(),肉眼核对结果。这一步在初学阶段的价值很高,剥离开界面的干扰,单独确认逻辑正确,再谈后续。
3.2 滑动合并的核心逻辑:先想清楚“三步走”
2048 的难点不在窗口和按键,而在“一次滑动后棋盘变成什么样”。以向左滑为例,一行四个格子要依次经历去除空格、相邻合并、再去除空格。为什么不能一边移动一边合并?看一个反例:一行是[2, 0, 2, 4],向左滑动正确结果是[4, 4, 0, 0]。如果原数组遍历时时碰到 0 就交换、碰到相等就合并,容易错处理成[2, 2, 4, 0],因为你把第二个 2 移动到第一个 2 旁边之后,没有机会再回头看一次。
所以主流做法是把一行抽出来,交给一个独立的合并函数处理:去除零值,把非零数字靠左排;然后从左到右找相邻相等,合并后把被合并位置留空;最后再压缩一次补上空隙。下面用mergeLine实现这一整套处理,注释标出每一步的目的。
// board.cpp —— 单行合并核心,line 长度为 4 bool Board::mergeLine(int line[4]) { int out[4] = {0, 0, 0, 0}; // 结果行 bool merged[4] = {false, false, false, false}; // 标记某位置是否已合并 int idx = 0; for (int i = 0; i < 4; ++i) { if (line[i] == 0) continue; // 跳过空格,相当于压缩 if (idx > 0 && out[idx - 1] == line[i] && !merged[idx - 1]) { out[idx - 1] *= 2; // 相邻相等:翻倍 merged[idx - 1] = true; // 标记该位置本轮不参与第二次合并 m_score += out[idx - 1]; } else { out[idx] = line[i]; // 否则原样放入结果行 merged[idx] = false; idx++; } } for (int i = 0; i < 4; ++i) line[i] = out[i]; // 写回原数组 return true; }这段代码最妙的细节是merged标记。它保证了一个数字在同一轮滑动里不会被合并两次。比如一行是[2, 2, 2, 2],期望结果是[4, 4, 0, 0]而不是[8, 0, 0, 0]。没有merged标记时,合并出来的 4 还可能继续跟后面的数字作比较,容易搞出连锁合并,不符合常规 2048 规则。这也是很多自己动手写 2048 的初学者卡住的经典位置,所以给merged单独加注释说明。
另一个隐藏细节是:合并时直接写在out[idx - 1]上,被合并位置的格子不再占用out的下标,自然形成了第二轮的“再压缩”。这个函数会把空位全挤到右侧,界面层只需要把 0 显示成空样式即可。
3.3 四方向复用的读行写行机制
mergeLine只处理“一条从左到右的线”。向上、向下、向右怎么办?常见做法是写四个几乎相同的复制函数,但这段代码会重复四份,以后改 bug 要改四个地方。更有效的做法是:把棋盘按方向读成一条线,交给mergeLine,再把处理后的线按原方向写回去。方向只影响读写顺序,不影响合并规则。
// 从 m_data 读取一条长度为 4 的线 void Board::readLine(int dir, int index, int line[4]) { for (int i = 0; i < 4; ++i) { switch (dir) { case 0: line[i] = m_data[index][i]; break; // 左:从左到右 case 1: line[i] = m_data[i][index]; break; // 上:从上到下 case 2: line[i] = m_data[index][3 - i]; break; // 右:从右到左 case 3: line[i] = m_data[3 - i][index]; break; // 下:从下到上 } } }读行结束后调用mergeLine(line),再写回。写回逻辑是读行的逆过程:左方向按列下标正序写入,右方向按逆序写入,上方向按行下标正序写入,下方向按逆序写入。这样 4 个方向统筹在一个move()函数里,算法思维也更接近工程实践。
分段测试在这个阶段很重要。写完mergeLine和readLine后,先构造一行数据调用合并,再测试整盘左移。测试用例别只测[2,2,2,2],还要测[2,0,2,4]、[4,4,8,0]、全空行、全满且无相邻相等的情况,后两者关系到游戏能否正确结束。
3.4 随机生成2和4、游戏结束判断
每次有效滑动后,棋盘必须在一个空位生成一个新数字。生成逻辑很简单:先收集所有 0 的坐标,随机选一个位置,按大约 90% 概率生成 2、10% 概率生成 4。随机关键要先用srand(time(0))设置随机种子,否则每次启动游戏生成的序列都一样。部分新版 C++ 环境也可以用<random>库替代,效果更均匀。
// 在随机空位生成 2 或 4 void Board::generateTile() { std::vector<std::pair<int, int>> empties; for (int r = 0; r < 4; ++r) for (int c = 0; c < 4; ++c) if (m_data[r][c] == 0) empties.push_back({r, c}); if (empties.empty()) return; int idx = rand() % empties.size(); m_data[empties[idx].first][empties[idx].second] = (rand() % 10 == 0) ? 4 : 2; }generateTile()只在move()判定棋盘发生了变化时调用,否则每次按键都强行塞一个新数字,游戏会加速失控。这个顺序写错是新手常见的隐蔽 bug:看起来每步都能动,但棋盘越来越大,很快就填满了。
游戏结束判断包含两层:棋盘满了没有空位,且所有相邻格子的值都不相等。注意“相邻”指上下左右四个方向,只要有一对相邻相等,就还能通过滑动合并腾出空间,不能判定结束。
// 检查是否还能移动 bool Board::canMove() const { for (int r = 0; r < 4; ++r) for (int c = 0; c < 4; ++c) { if (m_data[r][c] == 0) return true; if (r < 3 && m_data[r][c] == m_data[r + 1][c]) return true; if (c < 3 && m_data[r][c] == m_data[r][c + 1]) return true; } return false; }判定逻辑里最容易漏的是只判断了空位,没判断相邻相等,导致棋盘没满就提前弹出结束框。另一个常见错误是只检查了水平方向或只检查一个方向,这会导致“还能动”却被判死。实测时可以用一个几乎满盘但存在一对相邻相等的局面来验证,比如盘面右下角有一对 2 相邻。
4. 把算法接到界面上:Qt布局、键盘事件与刷新流程
4.1 界面控件选择:为什么用QGridLayout加QLabel而不是自绘
对初学者来说,四个方向键驱动一个 4x4 棋盘,最常见的界面搭法是用QGridLayout摆放 16 个QLabel。每个格子是一个独立的 Label,设置数字、对齐、字体和背景色就完成了棋盘渲染。这样做的理由很实际:不需要重写paintEvent,不需要处理局部刷新,16 个 Label 的性能开销在这个场景下完全可以忽略。
// mainwindow.cpp —— 构造 16 个格子并加入网格布局 QWidget *center = new QWidget(this); QGridLayout *grid = new QGridLayout(center); grid->setSpacing(8); for (int r = 0; r < 4; ++r) { for (int c = 0; c < 4; ++c) { m_cellLabel[r][c] = new QLabel("0", center); m_cellLabel[r][c]->setAlignment(Qt::AlignCenter); m_cellLabel[r][c]->setMinimumSize(90, 90); QFont font("Microsoft YaHei", 20, QFont::Bold); m_cellLabel[r][c]->setFont(font); grid->addWidget(m_cellLabel[r][c], r, c); } } this->setCentralWidget(center); this->setFocusPolicy(Qt::StrongFocus); // 关键:让主窗口能接收键盘事件这段布局代码里最值得注意的不是grid->addWidget,而是倒数第三行的setFocusPolicy(Qt::StrongFocus)。Qt 主窗口默认不接收键盘焦点,如果缺少这行,点击游戏区域后按方向键往往没有反应。设置强焦点后,主窗口自身可以响应按键。这是新手最容易困惑的“按了没反应”的经典原因之一。
16 个 Label 的背景色可以通过setStyleSheet()动态更新,也可以用setAutoFillBackground()配合调色板。初学阶段我用样式表,因为每个数字对应的颜色一目了然,改起来直观。数字为 0 时,建议把文本设为空字符串而不是显示“0”,否则棋盘看起来像堆满了数字 0,影响观感。
4.2 键盘事件处理:方向键、重开键与事件优先级
交互层只需要关心一个入口:重写主窗口的keyPressEvent。在这个函数里把按键映射到移动方向,调用数据层的move(),再根据返回值决定是否刷新界面。
// mainwindow.cpp —— 键盘事件入口 void MainWindow::keyPressEvent(QKeyEvent *e) { int dir = -1; switch (e->key()) { case Qt::Key_Up: dir = 1; break; case Qt::Key_Down: dir = 3; break; case Qt::Key_Left: dir = 0; break; case Qt::Key_Right: dir = 2; break; case Qt::Key_R: restartGame(); return; default: QMainWindow::keyPressEvent(e); return; } if (m_board.move(dir)) { refreshBoard(); if (!m_board.canMove()) { QMessageBox::information(this, "游戏结束", "棋盘已满,没有可合并的方块了"); } } }方向键映射用数字表示,与move()函数的约定保持一致:0 左、1 上、2 右、3 下。Key_R用来重开属于常见配置,放在switch里单独处理,不调用move()。这里的核心思想是“界面只负责读取结果,不参与规则计算”,数据层返回 false 时界面什么都不做,避免一次无效按键触发随机生成。
一个容易被忽略的小问题是键盘连击和重复按键。按住方向键不松手时,系统会持续发送按键事件,这会让游戏节奏变得过快。如果希望手感更稳,可以在keyPressEvent里判断e->isAutoRepeat(),自动重复的事件直接忽略。初学版可以先不做,但要做到心里有数,这是后来加手感时最先要处理的地方。
4.3 一次完整移动的刷新流程:数据先动,界面跟随
移动后的刷新流程非常机械:遍历数据层的 4x4 棋盘,把每个格子的值写到对应 Label 上,同时更新分数和颜色。
// mainwindow.cpp —— 用数据层刷新全部格子 void MainWindow::refreshBoard() { for (int r = 0; r < 4; ++r) { for (int c = 0; c < 4; ++c) { int value = m_board.cell(r, c); m_cellLabel[r][c]->setText(value == 0 ? "" : QString::number(value)); m_cellLabel[r][c]->setStyleSheet(colorForValue(value)); } } m_scoreLabel->setText(QString("分数:%1").arg(m_board.score())); }界面的全部工作就是这十几行。cell()接口返回 0 表示空格,界面显示空白;非零数字转成字符串显示。colorForValue是一个根据数值返回颜色字符串的辅助函数,可以用switch或查表实现,比如 2 浅灰、4 米黄、8 橙色、16 亮橙、32 红橙、64 红等。初学者可以按自己的喜好配,不用追求跟原版一致。
当一盘结束但还没到 2048 时,用QMessageBox弹窗最省事。弹窗会阻塞事件循环,用户点掉后才结束。如果只想提示一次,可以在refreshBoard里用成员变量记录是否已经弹过。还有一种做法是结束后把键盘事件直接忽略,直到用户按 R 重开,但这需要额外维护一个游戏状态字段。
5. 初学阶段最容易翻车的5个坑:编译、焦点与中文乱码排查
5.1 现象:方向键按了没反应,鼠标点击反而有光标闪现
这是 2048 Qt 项目里最频繁的求助问题。原因是 Qt 默认情况下按键事件被焦点所在控件接收,而主窗口本身不在焦点上。更隐蔽的是:如果你点击了某个QLabel,焦点在 Label 上,方向键事件被发给 Label,而 Label 不处理方向键,于是按键被吞掉。
解决思路分两步。第一步在构造函数里给主窗口设置setFocusPolicy(Qt::StrongFocus),这一步做了之后通常问题解决:窗口自身能接收焦点。第二步是如果游戏区域里还有子控件抢焦点,可以在refreshBoard()末尾或窗口显示事件里调用setFocus(),把焦点强制还给主窗口。
我见过用户把setFocusPolicy写错位置,加到了某个 Label 上,结果方向键依然没反应。排查时先确认这行代码在主窗口构造函数里,别写在格子的循环里。还有一个判断技巧:在keyPressEvent第一行qDebug() << e->key();,按方向键看控制台有没有输出,没有输出就是事件根本没到主窗口,问题在焦点侧;有输出但棋盘没变,问题在数据层。
5.2 现象:一次滑动合并了多次,2 2 2 2 直接变成 8
这不是界面 bug,是算法逻辑与官方规则不一致。2048 的标准规则是一次滑动中,每个方块最多参与一次合并。[2, 2, 2, 2]左移应该得[4, 4, 0, 0],如果写成从头到尾逐个比较if(line[i] == line[i+1]),第一个 2 和第二个 2 合并成 4 之后,还可能继续和后续数字比较,形成连锁合并。
解决办法就是用merged标记数组,合并过的位置不再参与第二次比较。具体代码已在 3.2 节给出。另一个易错点是合并完没有清空格子,比如line[i+1]保持原值,结果棋盘里出现两个重复数字互相干扰。每次合并后要确保被合并位变为 0,最终再压缩一次,把所有 0 放在行尾。
测试时建议把[2, 2, 2, 2]、[2, 2, 4, 4]、[0, 4, 0, 4]这三组情况都跑一遍。第一种验证单次合并限制,第二种验证同一次滑动中两个不同位置的合并能否同时发生,第三种验证带空位的合并顺序。三组全部符合预期,算法才算站得住。
5.3 现象:中文显示成乱码或编译时警告“编码不匹配”
这通常发生在 Windows 原生编译器和源码文件编码不一致时。Qt Creator 里源码默认按 UTF-8 保存,但某些 Windows 编译器把源文件当作本地编码读取,中文字符串就会解析错误。表现是运行后弹窗提示乱码,或者编译输出出现 warning。
解决办法第一选择是统一用 MinGW 工具链,一般不会触发这类问题。如果必须用 Windows 原生编译器,可以在 .pro 文件给编译器加 UTF-8 编译选项,让编译器明确按 UTF-8 读取源码。同时确认 Qt Creator 里“编辑器 -> 文件编码”设置为 UTF-8,并在代码里避免混用编码,比如某些文件 UTF-8、某些文件 GBK。
另一种隐蔽情况是源码在打包或复制过程中被转成了带 BOM 的 UTF-8,编译器对 BOM 处理不一致可能导致第一行报错。处理办法是用 Qt Creator 的另存为功能重新选择“UTF-8(不带 BOM)”保存。这个坑不致命,但排查成本高,建议一开始就把所有源文件编码统一。
5.4 现象:新加一个源文件,编译时报“未定义的引用”
初学项目通常用 .pro 管理文件列表。你在项目目录里手工创建了scoreboard.cpp,也写了#include "scoreboard.h",但忘了把它加进 .pro 的SOURCES列表。链接阶段编译器找不到对应函数实现,报错信息里全是 undefined reference,指向你调用的函数名,而不是缺失的文件名。
解决方法是回到 .pro 文件,把新文件加入SOURCES和HEADERS。在 Qt Creator 里右键项目文件夹选“添加新文件”,IDE 会自动更新 .pro;但如果你用外部编辑器创建文件,就要手动处理。改过 .pro 之后 Qt Creator 会提示重新构建,别跳过,否则旧 Makefile 仍然不包含新文件。
还有一个伴生坑:头文件改了,但构建系统没有重新编译依赖它的 cpp。遇到这种情况先执行“清理”再重新构建,通常能解决。不要盲目删除代码,先确认新文件是否进入编译列表,是最高效的排查路径。
5.5 现象:把exe拷到别的电脑,打开提示缺少一堆DLL
Qt 程序的运行依赖框架动态库,直接拷贝 exe 到没有装 Qt 的电脑当然跑不起来。这不算代码 bug,但初学阶段很容易被误认为“我写的东西有问题”。其实只需要在 exe 同目录执行一次部署工具,把依赖的库文件一起拷走即可。
# 在 Qt 命令行环境中定位到 exe 所在目录 cd build/release windeployqt 2048.exewindeployqt会自动把需要的 Qt 库、插件和依赖项拷贝到 exe 所在目录。执行后目录里会出现一批Qt5Core.dll、Platforms文件夹之类的内容,整个目录一起打包分享,换台电脑双击就能跑。注意命令要从 Qt 提供的命令行环境里执行,确保windeployqt在 PATH 里。
如果用的是 MinGW 工具链,有时还需要额外携带对应的 C++ 运行库。最简单稳妥的验证方式是在一台没有 Qt 的虚拟机或另一台电脑上测一次。初学分享作品时,建议源码和构建说明一起发,对方装上 Qt 就能重新构建;只发一个裸的 exe 是最容易制造困惑的分享方式。
6. 进阶三种玩法:加入动画、撤销与最高分持久化
6.1 给移动加上动画:从“跳变”到“滑过去”
当前实现里,每次移动后数字是直接刷新到新位置的,没有任何过渡。加动画常见是用QPropertyAnimation改变 Label 的坐标或使用QGraphicsItem体系。一个折中方案是保留 QGridLayout,但移动时先让 Label 按偏移量做位置动画,动画结束后再刷新布局,视觉上是滑过去的,工程改动量也不大。动画期间要配合isAutoRepeat禁掉连续按键,否则棋盘还没到位就触发下一次逻辑处理,画面会乱。
6.2 撤销一步:用状态栈给游戏一颗后悔药
撤销功能是所有棋类游戏的心愿。做法是在每次有效移动前,把当前棋盘数组和分数保存到一个历史栈里,撤销时从栈顶恢复并刷新界面。
// 撤销实现的最小示例 struct Snapshot { int data[4][4]; int score; }; std::vector<Snapshot> m_history; void MainWindow::onMoveBefore() { Snapshot s; memcpy(s.data, boardData(), sizeof(s.data)); s.score = currentScore(); m_history.push_back(s); if (m_history.size() > 10) m_history.erase(m_history.begin()); // 限制栈深度 } void MainWindow::undo() { if (m_history.empty()) return; Snapshot s = m_history.back(); m_history.pop_back(); restoreSnapshot(s); refreshBoard(); }栈深度建议限制在 10 步以内,防止长时间游戏内存暴涨。撤销后不要恢复随机种子,保持后续生成序列自然推进。
6.3 最高分持久化:QSettings比手写文件更省事
最高分功能需要保存到本地。正规做法是直接用QSettings,它底层自动处理注册表或配置文件,跨平台一致。只需要在游戏结束或每轮移动后比较当前分数与已存最高分,更新数值即可。初学阶段不建议手写文本文件解析,QSettings一行写入一行读取,把精力留给更有意思的功能。
我自己做这个项目时最后悔的,是一直埋头改算法,直到很早版本才补上merged的标记和焦点处理。这两个问题其实都可以在写代码时就预判到,而预判靠的是把“数据层”和“界面层”分清楚的工程习惯。希望这篇笔记能帮你在初学阶段少踩几个坑,把时间留给真正有趣的部分。
本文还有配套的精品资源,点击获取