C++实战:从零开发2048小游戏并用Qt打包发布
2026/9/8 4:20:28 网站建设 项目流程

简介:一份基于Qt4开发的2048小游戏C++入门源码包,面向刚开始学习C++与图形界面编程的读者。项目用面向对象方式拆分了三个核心类:GameBoard维护棋盘状态与滑动合并规则,Tile表示方块数值与位置,GameManager控制流程、判定结束并记录得分;同时借助Qt的信号槽机制处理用户上下左右滑动及界面刷新,能帮助理解封装、事件处理、控件布局和GUI设计的基本思路。压缩包共6个文件,以2个cpp源文件、1个头文件、1个ui界面文件和1个pro工程文件为主,另有1个user配置文件,整体仅7KB,代码量小、结构清晰,可直接用Qt Creator打开运行调试,便于逐行研读。界面部分基于Qt4的QGraphicsView/QGraphicsItem实现,涉及窗口构建、图形绘制与交互反馈等常见知识点,便于初学者对照源码理解Qt工程组织方式。目前已有265人学习下载,适合作为C++/Qt4入门练习或小型课程设计参考。 最近整理旧电脑,翻出了一个多年前写的C++小项目——2048qt小游戏C++初学.zip。当时我刚学完C++基础语法,想找一个能练手又不至于太劝退的项目,最后选了2048。这个游戏的规则大家都懂,就是4x4方格里的数字方块,滑动方向,相同数字合并翻倍。用Qt来做界面,比控制台版本直观很多,而且整个项目逻辑并不复杂,非常适合C++初学者快速建立起写完整程序的信心。

现在很多人来问这个压缩包里的源码怎么编译、怎么跑起来、为什么打包后换台电脑就报错。所以我把这个项目的完整开发过程、算法思路、Qt界面设计、以及我在打包和调试过程中踩过的坑都整理成文。不管你是刚学完C++面向对象想找个小项目练手,还是已经会写控制台版2048想升级成GUI,这篇文章都值得看一下。

这篇文章为什么值得读?因为它不是直接丢给你一堆代码,而是带着你把2048的核心算法一步步拆开讲清楚,再把Qt界面的最小实现方案给你,最后还会告诉你如何解决常见的Qt运行时报错。读懂这些,你完全可以独立写出自己的2048,而不是只会复制粘贴。

1. 项目背景与整体设计思路

1.1 为什么2048是最适合C++初学者的练手项目

我在决定用什么项目入门C++时,其实看过不少选择。贪吃蛇控制台版太简单,写完没有成就感;写一个万年历或学生管理系统又太偏向业务逻辑,和游戏开发的感觉完全不一样。2048最合适的地方在于规则简单、算法独立、界面可以做得非常清爽。

对一个刚学完vector、循环和类的初学者来说,2048的核心逻辑就是一个4x4二维数组的读改写。你不需要理解复杂的物理引擎,不需要处理网络,甚至不需要引入第三方库。你只需要把一个方向键按下之后,棋盘上的数字按规则移动和合并这一件事想清楚就够了。这个复杂度刚好卡在"需要动脑但不至于放弃"的位置上。

更重要的是,这个项目能够覆盖C++初学者最需要掌握的几项基本功:STL容器(vector)的使用、类的设计与封装、函数参数传递与返回值、以及基本的算法思维。写完这个项目之后,你会对"一个程序有哪些模块、模块之间怎么配合"有非常直观的认识。

1.2 为什么选择Qt而不是其他GUI框架

控制台版本写完之后我想过要不要继续写GUI,当时Windows下常见的选择有MFC、WPF、Qt和Win32。对初学者来说,MFC太老且封装混乱,Win32纯API完全是折磨,WPF绑定到C++/CLI又谈不上跨平台。Qt的优势在于:第一,安装方便,Qt Creator开箱即用;第二,信号槽机制让界面和游戏逻辑可以解耦,适合我们这种"先把逻辑写好再套界面"的开发顺序;第三,中文资料特别多,遇到问题基本都能搜到答案。

另外,Qt的QSS样式表有点像写CSS,可以快速调整界面观感。2048这种游戏对动画要求不高,用QGridLayout加QLabel就能摆出棋盘,一点都不复杂。如果你以后想做更复杂的东西,Qt还能做嵌入式、桌面软件甚至Android应用,学一次可以用很久。

1.3 项目文件结构与模块划分

拿到压缩包之后你看到的源码应该是这样的目录结构,这也是我当时反复调整后留下的:

  • 2048.pro:工程文件,Qt Creator会用它来构建项目
  • main.cpp:程序入口
  • mainwindow.h / mainwindow.cpp:主窗口类,负责界面显示和键盘事件
  • board.h / board.cpp:游戏逻辑类,负责棋盘数据、移动合并、胜负判断

这种模块划分遵循了一个很朴素的原则:数据和显示分离。Board类完全不关心界面怎么画,MainWindow类也不直接操作棋盘数组,而是通过调用Board类的方法获得结果,然后刷新界面。这样做的好处是以后如果你想换个界面框架,或者把2048算法移植到另一个项目里,可以直接把board.h和board.cpp拖过去用。

2. 核心算法实现:2048的移动与合并逻辑

2.1 数据结构选型

我最初用的是一维数组int board[16],用下标index = row * 4 + col来访问,但发现移动和合并处理起来容易懵。后来换成std::vector<std::vector >,这样更符合C++习惯,而且代码可读性高很多。

这里没有用QVector是因为我希望让逻辑层尽量不依赖Qt,保持纯C++风格。如果你以后想把逻辑测试单独跑在命令行里,甚至专门写单元测试,这样的设计会方便得多。

棋盘的初始状态是4x4,我用一个构造函数给board_赋值:

board_.resize(4, std::vector<int>(4, 0));

然后调用addRandomTile()两次,往空白位置随机填两个初始数字。

2.2 移动与合并的核心思路:理解"先移动再合并"

很多人写2048的移动逻辑时,脑子会一下陷入到"遍历每一个格子,然后判断能否移动"的复杂情况里。我的做法更简单:把移动分为两个阶段——压缩(压缩掉所有空格)和合并(相邻相同数字相加)。以向左移动为例:

第一步,把某一行的非零数字按顺序靠到左边,忽略零。比如一行原来是 [2, 0, 2, 4],压缩后变成 [2, 2, 4, 0]。

第二步,从左边开始检查相邻两个数字是否相等。如果相等,就合并到左边的位置,右边的位置置零,并且分数累加。关键点在于,每次合并只能合并一次,不能连锁合并。

第三步,再次压缩,因为合并之后中间可能出现新的零,比如 [4, 4, 8, 8] 左移后应该得到 [8, 16, 0, 0],而不是 [16, 0, 0, 0] 或者 [8, 16, 0, 0] 之外的其他结果。

我给出一个向左移动的简化实现,核心就三个循环:

bool Board::moveLeft() { bool changed = false; for (int row = 0; row < 4; ++row) { std::vector<int> line; for (int col = 0; col < 4; ++col) { if (board_[row][col] != 0) { line.push_back(board_[row][col]); } } while (line.size() < 4) line.push_back(0); std::vector<int> newLine(4, 0); int pos = 0; int i = 0; while (i < 4) { if (line[i] != 0 && i + 1 < 4 && line[i] == line[i + 1]) { newLine[pos] = line[i] * 2; score_ += newLine[pos]; i += 2; } else { newLine[pos] = line[i]; i += 1; } ++pos; } for (int col = 0; col < 4; ++col) { if (board_[row][col] != newLine[col]) changed = true; board_[row][col] = newLine[col]; } } return changed; }

这段代码最关键的点是i += 2:一旦两个相邻数字合并,跳过一个格子,避免重复合并。这个技巧我称之为"跳格合并法"。其他三个方向的移动可以不用另写三套逻辑,而是通过旋转或行列转换后调用同一个移动函数,但为了初学者阅读方便,我当时直接按方向分别写了四个函数,虽然代码有重复,但逻辑一目了然。

2.3 随机数字生成与游戏结束判定

每移动一步之后,系统需要在空位上随机生成一个2或4。为了贴近原版效果,我这里写了一个简化的随机逻辑:

void Board::addRandomTile() { std::vector<std::pair<int, int>> empty; for (int row = 0; row < 4; ++row) { for (int col = 0; col < 4; ++col) { if (board_[row][col] == 0) empty.push_back({row, col}); } } if (empty.empty()) return; int index = rand() % empty.size(); board_[empty[index].first][empty[index].second] = (rand() % 10 == 0) ? 4 : 2; }

游戏结束的判断也很简单:遍历所有格子,如果存在0,或者存在两个相邻格子值相等,就说明还能继续。你不需要同时判断上下左右和有无空位这两件事,只需把条件整理成"是否可以移动"这样一个函数。

2.4 为什么移动逻辑要放在Board类里,而不是写在界面类里

我当时第一版把移动逻辑直接放在MainWindow里,写完之后发现界面代码里全是算法,改起来非常痛苦。后来我单独抽出Board类,界面只关心调用bool change = board.moveLeft();之后,根据返回值决定要不要刷新棋盘、要不要处理结束状态。这样一来,界面代码干净了很多。这也是面向对象编程里"职责单一"原则的一个小小实践。

3. Qt界面设计与事件处理

3.1 棋盘布局:QGridLayout加QLabel

如果你在Qt Creator里新建的是QMainWindow,最简单的棋盘布局就是用一个QGridLayout,放置16个QLabel。每个QLabel负责显示一个格子的数字和背景色。

实际代码里,我先建立一个纵向的QVBoxLayout作为主布局:

QGridLayout* gridLayout = new QGridLayout; for (int row = 0; row < 4; ++row) { for (int col = 0; col < 4; ++col) { QLabel* label = new QLabel("0", this); label->setAlignment(Qt::AlignCenter); label->setMinimumSize(80, 80); label->setFont(QFont("Microsoft YaHei", 18, QFont::Bold)); labels_[row][col] = label; gridLayout->addWidget(label, row, col); } } setCentralWidget(gridLayout); // 如果继承QMainWindow,需要先把grid放到QWidget里

这里有一个细节,我提前定义了一个成员变量QLabel* labels_[4][4],不需要每一次刷新界面时都去遍历布局找控件。这样在updateBoard()函数里就可以直接通过下标更新每个标签的内容和样式,大大减少代码量。

我当时还犯过一个错误:创建完控件后忘记调用layout->setSpacing(10),导致16个格子挤在一起,后来加上间距后视觉效果立刻好了很多。2048游戏本身有固定的配色,不同数字对应不同深浅的方块颜色,这个我是用一个辅助函数来生成样式表的。

QString colorForValue(int value) { switch (value) { case 0: return "background-color: #CDC1B4; color: #776E65;"; case 2: return "background-color: #EEE4DA; color: #776E65;"; default: return "background-color: #EDC22E; color: #F9F6F2;"; } }

这里的数字到颜色映射其实可以参考原版2048的配色。0格是灰棕色,2和4是浅色,后续数字越深。你可以自己配上好看的颜色,不用完全照搬。

3.2 键盘事件处理:方向键的监听与焦点问题

在Qt中处理键盘事件,需要在你希望接收焦点的窗口部件里重写keyPressEvent。对于主窗口,通常这样写:

void MainWindow::keyPressEvent(QKeyEvent* event) { bool changed = false; switch (event->key()) { case Qt::Key_Left: changed = board_.moveLeft(); break; case Qt::Key_Right: changed = board_.moveRight(); break; case Qt::Key_Up: changed = board_.moveUp(); break; case Qt::Key_Down: changed = board_.moveDown(); break; default: QMainWindow::keyPressEvent(event); return; } if (changed) { board_.addRandomTile(); updateBoard(); } if (board_.isGameOver()) { // 显示游戏结束 } }

一个很常见的坑是:子控件(比如某个QLabel)抢占了键盘焦点,导致主窗口的keyPressEvent根本不会被调用。解决办法是设置主窗体的焦点策略:

setFocusPolicy(Qt::StrongFocus);

同时在主窗体构造函数里调用setFocus(),确保窗口启动时就处于焦点状态。我当时调试了好几个小时,才发现原来是布局里的某个控件一直持有焦点,键盘怎么按都没反应。

3.3 刷新棋盘:一行代码更新所有格子

界面刷新不需要什么高深技巧,就是遍历数组,把对应的label更新掉。这里要注意的是值0要显示为空字符,否则界面上会有一堆"0",不够美观。

void MainWindow::updateBoard() { scoreLabel->setText(QString("分数: %1").arg(board_.score())); for (int row = 0; row < 4; ++row) { for (int col = 0; col < 4; ++col) { int value = board_.getValue(row, col); labels_[row][col]->setText(value == 0 ? "" : QString::number(value)); labels_[row][col]->setStyleSheet(colorForValue(value)); } } }

由于2048棋盘只有16个格子,这种全量刷新的性能完全不是问题。你不需要考虑只刷新变化的格子,那反而会增加代码复杂度。

3.4 界面与逻辑的联动方式

在写代码的过程中,我复习了一下Qt的信号槽,但在这个项目里其实没有用到自定义信号,而是采用了一种更直接的方式:界面类持有逻辑类的一个对象,在键盘事件里直接调用逻辑方法,然后更新界面。

这种"以逻辑类为主动方、界面类为被动方"的写法对于初学者来说最容易理解。等你以后要写更复杂的项目,自然会用信号槽来做事件解耦。但现阶段请不要过度设计,先把功能跑通,再想优雅的事。

4. 工程配置、编译与打包发布

4.1 Qt Creator工程配置与.pro文件

我用的是Qt 5.15.2,编译器选择MinGW 64位。创建工程时,在Qt Creator里选择"Qt Widgets Application",这样会自动生成main.cpp和mainwindow相关文件。然后我把board.h和board.cpp手动拷贝到项目目录,并在.pro文件里加上对应的源文件。

一个最简单的2048.pro大致长这样:

QT += core gui widgets TARGET = 2048 TEMPLATE = app CONFIG += c++11 SOURCES += \ main.cpp \ mainwindow.cpp \ board.cpp HEADERS += \ mainwindow.h \ board.h

在实际开发中,你不需要手动编辑.pro里的每一行,Qt Creator会在添加文件时自动更新。不过你要留意CONFIG += c++11或者c++17,因为某些C++新特性需要显式指定标准。

4.2 编译时遇到"could not find a Qt platform plugin"怎么办

热词里有一句很经典的报错:"windows no qt platform plugin could be initialized reinstalling the applicat"。这句话涉及的问题,是Qt程序运行时找不到对应的平台插件。最常见的原因是,你编译出的exe在未安装Qt的电脑上运行,而exe旁边的platforms目录里缺少qwindows.dll。

解决办法有两个:

第一个,如果是开发阶段在Qt Creator里点运行,出现这个问题多半是环境变量异常或Qt版本不匹配。检查一下"构建套件(Kit)"是否选对了,不同编译器(MinGW/MSVC)和Qt版本不能混用。

第二个,如果是发布阶段把这个exe直接拷给别人,最简单的就是用Qt自带的部署工具windeployqt。打开命令行,切换到exe所在目录,执行:

windeployqt 2048.exe

它会自动把依赖的Qt库、插件和必要的运行库复制到当前目录,包括platforms/qwindows.dll。

4.3 使用windeployqt打包发布全流程

我一般是这样打包一个不带安装包的绿色版程序的:

  • 用Release模式编译项目,得到2048.exe
  • 在开始菜单里找到Qt对应的命令行工具(比如"Qt 5.15.2 (MinGW 8.1.0 64-bit)"),打开后cd到exe目录
  • 运行windeployqt 2048.exe,等待它自动复制文件
  • 把整个文件夹压缩分发,对方解压就能运行

需要注意,如果你使用了MinGW编译器,还需要把libgcc_s_seh-1.dll、libstdc++-6.dll、libwinpthread-1.dll这些动态库一起带过去。windeployqt通常会自动处理,但如果你手动清理过文件,可能会漏掉。别问我怎么知道的,我在朋友电脑上见过太多次"无法启动此程序,因为计算机中丢失libgcc_s_seh-1.dll"的提示。

4.4 在VSCode中配置Qt/C++开发环境

有一部分初学者不喜欢用Qt Creator,强行在VSCode里配置Qt项目。VSCode配置C++环境本身不难,装好MinGW-w64并配置好tasks.json和launch.json就可以编译,但要让Qt里的头文件和库被找到,还需要在c_cpp_properties.json里添加Qt的include路径和编译器的include路径。

说实话,我刚开始也这么折腾过,但后来还是回到Qt Creator了。原因很简单:Qt Creator对Qt项目的支持是最完整的,自动补全、UI设计器、qrc资源管理都开箱即用。VSCode更适合你熟悉了Qt之后,需要一个更轻量编辑器的情况。如果你坚持要在VSCode里配置,建议使用CMake接Qt,而不要手搓命令行编译,这样可以省去很多麻烦。

4.5 关于常见Qt网络报错的一句话提醒

热词里有一条"qt request method 'post' not supported",这实际上是因为Qt的HTTP请求里使用了服务器不支持的POST方法,或者代理设置有问题。这个和2048小游戏完全没关系,但如果你以后在Qt项目里写网络模块时遇到,可以检查一下请求头、URL以及服务器接口是否真正支持POST。问题本身也说明,Qt开发的坑往往不在于语言,而在于对平台和库的理解。

5. 常见问题与排查技巧实录

5.1 方向移动后数字合并错误:为什么一行4个4不应该变成16

这是初学者最常问的问题。拿一行 [4, 4, 4, 4] 来说,左移之后应该变成 [8, 8, 0, 0],而不是 [16, 0, 0, 0]。原版2048的规则是,一次滑动中同一个数字只能合并一次。所以最左边的两个4合并成8,接着的后两个4合并成另一个8,一共有两个8,而不能继续让两个8合并成16。

如果你是按我上面的"跳格合并法"写循环,就不会出这个问题。因为一旦合并,i += 2直接跳过了后一个格子。但如果你的逻辑是从靠近起始端的方向开始遍历,并且每发现相邻相等就合并,那么 [4, 4, 4, 4] 可能会被处理成 [16, 0, 0, 0],这就是bug。

调试这类问题最好的办法,就是在移动函数里加一行qDebug输出:

qDebug() << "before:" << board_[row][0] << board_[row][1] << board_[row][2] << board_[row][3];

跑一次方向键,看输入输出,问题立刻就能定位。

5.2 键盘方向键没有反应

我先检查环境:窗口是否获得了焦点?有没有设置setFocusPolicy(Qt::StrongFocus)?有没有在keyPressEvent里不小心调用QWidget::keyPressEvent导致事件被提前处理?

还有一个可能,你用的是图形视图框架(QGraphicsView),按键事件发生在scene的某个item里,而主窗口没收到事件。本项目的QGridLayout+QLabel方案不会出现这种问题,但如果以后你做更复杂的窗体会遇到。

另一种情况是键盘事件被重复触发,按下没抬起,但事件不断发送。这是因为没有处理自动重复。你可以通过判断event->isAutoRepeat()来忽略按住不放的自动重复事件。这个细节会在调试时体现出专业度。

5.3 界面上的数字没有实时更新

如果你移动成功,但界面没有变化,很可能是移动函数返回的changed没有正确控制刷新。或者你在更新时,只更新了文字,忘记更新样式表。我建议把所有界面刷新统一封装到一个updateBoard()函数里,每次都完整地更新文本和颜色,避免不一致。

另一种可能是在board_的copy过程中出问题。比如你传参时用的是临时副本,移动操作没有作用在真正的成员变量上。检查一下函数签名,确保std::vector<std::vector<int>>&而不是std::vector<std::vector<int>>值传递。

5.4 游戏结束提示过早或过晚

我的判断逻辑中,isGameOver()返回true表示不能移动。如果你只检查有没有空格,那么当棋盘满但没有相邻相等值时,游戏应该结束,却因为空格不存在而直接结束,但此时如果有相邻相等其实还能移动。所以完整的条件必须是:没有空格,而且任意相邻格子都不相等。

这个逻辑可以拆成两步:先判断有没有空格,再遍历行和列检查相邻值。注意别遗漏列的方向。

5.5 辅助调试的小技巧:让游戏过程可见

我自己的经验是,在Board类里写一个printBoard()函数,用cout输出整个棋盘到控制台。这样写算法阶段,根本不需要打开界面就能验证移动逻辑。等你把逻辑调对了,再去做界面,调试速度会快很多。

void Board::printBoard() { for (int row = 0; row < 4; ++row) { for (int col = 0; col < 4; ++col) { std::cout << board_[row][col] << "\t"; } std::cout << std::endl; } }

6. 从初学到进阶:这个项目还能怎么扩展

6.1 动画效果:让数字移动不那么生硬

原版2048的方块移动带有一个平滑动画。在Qt里可以用QPropertyAnimation对QLabel的位置或缩放进行动画,不过这需要你从QLabel自定义一个可绘制棋盘窗口部件,而不是简单用QGridLayout。如果你有兴趣,可以尝试重写paintEvent,用QPainter绘制方块并根据移动目标位置插值,这个扩展能学到不少Qt绘图的技巧。

6.2 存档与排行榜:涉及文件读写和数据库

给2048加入存档功能,需要你掌握QFile、QDataStream或JSON序列化。把当前棋盘数组、分数、最高分保存到本地,再启动时加载,这是非常典型的C++文件操作练习。还可以做一个本地排行榜,直接读写文本文件就够了,不需要引入数据库。

6.3 使用QML重写界面:从Widgets走向现代Qt

Qt除了QWidgets,还有一套QML语言,适合做更漂亮的界面。2048这种格子型游戏用QML写其实更自然,你甚至可以用Repeater快速生成16个格子,动画也内置支持。如果你想了解Qt的现代开发方式,把这个项目用QML重写一遍是个不错的练习。

6.4 真正理解算法:从2048到更广的棋盘游戏

2048的移动算法本质上是二维数组的状态变换。理解了"压缩-合并-再压缩"这套思路,以后你再写消消乐、数独或者其他棋盘游戏时,处理行列的逻辑会变得更顺手。我甚至见过有人用2048来练手强化学习算法,那就完全超出C++初学范围了。

如果你也打算拿这个项目练手,我的建议是先不看任何现成代码,自己把移动函数写出来,跑通之后再对照其他人的实现优化。这个过程里犯的错比看十遍教程都有价值。等你把整个项目从控制台版本做到Qt界面,再打包发给朋友玩,那种成就感就是最好的学习动力。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询