简介:一个基于C++与Qt4实现的2048小游戏完整工程,借助Qt4绘制棋盘与方块,玩家通过滑动合并数字,体验经典益智玩法。工程面向C++初学者,清晰展示了封装、继承、多态等面向对象特性,并利用Qt4的信号与槽完成交互响应,能帮助读者快速上手GUI程序设计。压缩包共6个文件,以cpp/h源码为主,另含Qt Designer的ui界面文件、pro工程配置及user文件,整体仅7KB,结构紧凑,便于逐文件阅读。已有265人学习下载,尤其适合刚学完C++语法、希望尝试实际小项目的入门开发者。学习时可重点研究GameBoard、Tile、GameManager等类的协作方式,理解棋盘状态维护、方块合并逻辑与游戏流程控制,并体会QGraphicsView框架下的图形渲染思路。通过阅读和修改代码,还能掌握按钮事件绑定、键盘监听、棋盘绘制更新等实用技能,学会将游戏逻辑与界面分离,形成清晰的模块化设计意识,从而获得从零搭建一个桌面小游戏的完整实践体验。 手头这个压缩包,名字叫“2048qt小游戏C++初学.zip”,是我前一阵子带一个刚学完C++基础的朋友做的小项目。说句实话,对于初学者来说,2048这个游戏简直就是为练手量身定做的:规则简单到一句话能讲清楚,但要把移动、合并、随机生成、胜负判断这堆逻辑写明白,又确实能逼你把数组、函数、事件响应、界面刷新这些基本功全部过一遍。再加上用Qt做图形界面,比在黑窗口里跑数字有成就感太多了——做完能双击打开、能玩、能打包发给朋友炫耀,这种正向反馈对新手来说比什么鸡汤都管用。
这篇文章就把整个项目的来龙去脉拆开讲一遍,从架构设计、核心算法、界面绘制到打包发布,全部按我实际操作的顺序来。不管你是刚学完C++语法正愁没项目练手,还是已经写了几个控制台程序想试试图形界面,这篇内容都能帮你少走不少弯路。
1. 项目定位与整体设计思路
1.1 为什么选2048而不是别的游戏
初学者做项目,最怕的就是两头堵:太简单的没意思,太复杂的看不懂。扫雷、贪吃蛇、俄罗斯方块也都是经典选择,但我最终推荐2048,是因为它在逻辑复杂度上卡得刚刚好。
2048的核心逻辑可以拆成四个独立模块:格子的数据存储、数字的移动合并算法、新数字的随机生成、游戏结束判定。这四个模块各自独立,又能串成一条完整的游戏流程。而且它没有传统游戏里最折磨人的“实时刷新”问题——不像贪吃蛇需要定时器驱动每帧移动,2048是“玩家按一下,界面变一下”,天然就是事件驱动模型,和Qt的信号槽机制完美契合。
相比之下,俄罗斯方块要处理方块旋转、消行、下落计时器,逻辑复杂度直接翻倍;贪吃蛇虽然也不难,但蛇身移动的坐标管理对新手来说容易绕晕。2048的棋盘就4x4共16个格子,数据量小,就算用最笨的算法也不会有性能问题,新手可以放心大胆写,不用一开始就纠结优化。
1.2 整体模块划分与文件组织
我建项目的时候直接用了Qt自带的QWidget Application模板,没有碰QML,因为QML虽然写界面快,但它是声明式语言,和C++的核心逻辑是分开的,对新手来说等于同时学两样东西。用QWidget + C++,所有代码就是一种语言,思维不割裂。
整个项目的文件组织很简单:
main.cpp:程序入口,创建主窗口gamewidget.h / gamewidget.cpp:主窗口类,负责界面绘制和键盘事件gameboard.h / gameboard.cpp:游戏逻辑类,负责数据存储和核心算法tile.h / tile.cpp:可选,封装单个格子的数据和颜色(新手阶段也可以不建这个类,直接用二维数组)
逻辑层和界面层分离是这个小项目里最重要的架构思想。GameBoard只管数据,不管怎么画;“按上键之后数据怎么变”和“画面上显示什么颜色”完全不掺和。这样做的好处是:你想把界面从QWidget换成别的框架,逻辑代码一行不用改;想写单元测试,也只需要测GameBoard,不需要打开窗口。我之后调试算法的时候,直接在main.cpp里写几行测试代码就能验证结果,根本不用启动GUI,光是这一点就省了大量时间。
1.3 开发环境选型说明
我用的是Qt 5.15.2 + Qt Creator + MSVC2019 64位编译器。如果你的机器是MinGW版本的Qt也没关系,代码完全通用,只是最后打包时要带的运行库不同,这个后面会专门讲。Qt 5.15.2是5系列的最后一个长期维护版本,网上资料最多,遇到报错一搜就能找到答案,对新手极其友好。我个人不太建议一上来就追最新的Qt 6.x,不是说它不好,而是6.x有些模块还在调整期,初学者分不清“自己写错了”和“版本差异”的区别,排查问题会多一层干扰。
2. 核心数据结构和游戏逻辑
2.1 棋盘数据存储方案
2048的棋盘是一个4x4的方格,所以最直观的存储方式当然是二维数组。但在实际动手之前,我建议先想一个问题:这个数组要不要支持动态扩展?
我的回答是不要。经典2048就是固定4x4,做成5x5属于魔改玩法,会牵涉到算法复杂度变化,新手阶段没必要。就用固定尺寸:
const int BOARD_SIZE = 4; int board[BOARD_SIZE][BOARD_SIZE] = {0};board[r][c]表示第r行第c列的格子数值,0代表空格。这个方案简单粗暴,内存只占256字节,读写直接寻址,没有比这更高效的方式了。
有人可能会问,为什么不用一维数组int board[16]?也可以,而且一维数组在做“按行滑动”的时候下标更好算。但二维数组board[row][col]更符合人类的直觉,代码可读性强。2048这种量级的数据,性能完全不是瓶颈,可读性优先才是对的。
2.2 数字移动合并算法
这是整个项目的灵魂,也是新手最容易卡住的地方。先声明:网上有很多“高大上”的做法,比如用位运算、用矩阵转置,但这些对新手都不友好。我用的方法是“提取非零数字—合并—补零”,每一步都直观得不行,而且效率也够用(4x4就这么点数据,再怎么跑都是微秒级)。
以“向左移动”为例,核心思路是逐行处理。每一行单独拿出来,单独完成移动合并,和其他行互不影响。
第一步:提取非零数字。把当前行所有非零数字按顺序取出来放到一个临时数组里,保留原来的相对顺序。比如一行原始数据是[2, 0, 0, 2],提取后得到[2, 2]。
第二步:合并相邻相同数字。从左往右看临时数组,如果相邻两个元素相等,就合并成一个,值翻倍,左移一格。注意这里有个坑:合并之后,被合并的元素不能再参与后续合并。比如[2, 2, 2, 2],正确处理结果是[4, 4],而不是从右往左合并得到[2, 4, 2]或者连加得到[8]。标准2048的合并规则是一步之内每个格子最多参与一次合并。
第三步:补零。把合并后的数组重新填回原始行,从左边开始放,剩余位置补0。
用代码表示就是:
bool GameBoard::moveLeft() { bool changed = false; for (int row = 0; row < BOARD_SIZE; ++row) { std::vector<int> tmp; for (int col = 0; col < BOARD_SIZE; ++col) { if (board[row][col] != 0) tmp.push_back(board[row][col]); } // 合并阶段 for (int i = 0; i < (int)tmp.size(); ++i) { if (i + 1 < (int)tmp.size() && tmp[i] == tmp[i + 1]) { tmp[i] *= 2; tmp.erase(tmp.begin() + i + 1); score += tmp[i]; } } // 填充回原行 for (int col = 0; col < BOARD_SIZE; ++col) { int newVal = col < (int)tmp.size() ? tmp[col] : 0; if (board[row][col] != newVal) changed = true; board[row][col] = newVal; } } return changed; }注意这个函数返回bool,表示这一步骤有没有产生任何数字位置或值的变化。为什么要这个返回值?因为如果玩家按了一个方向,但棋盘没发生任何变化,那就不应该生成新数字,也不能算作一次有效操作。这个细节很容易被忽略,但却是保证游戏逻辑正确的关键。
至于向右、向上、向下,我没写三个几乎相同的函数。最简单省事的做法是:定义一个rotate操作(顺时针旋转90度),那么“向右移”就等于“先把棋盘旋转180度,向左移,再旋转180度”;“向上移”等于“旋转90度,向左移,再逆时针旋转90度”。用旋转来统一四个方向,代码量直接减少一大半,也不容易写出方向搞反的bug。新手可以先把左移写对、测透,再用旋转的方式实现其他方向。
2.3 新数字生成与胜负判定
每次有效移动后,棋盘会随机在一个空格位置生成一个新数字,90%概率是2,10%概率是4。这块逻辑没什么难度,但有两个小坑:
第一,随机数生成。新手很容易写rand() % 4,然后发现每次启动游戏生成的随机序列都是重复的。一定要用srand(time(0))初始化种子,或者直接用Qt封装好的QRandomGenerator::global()->bounded(4),后者从Qt 5.10开始就是这个领域的最佳实践,线程安全,种子自动管理,省得你记挂着srand。
第二,当你尝试往一个格子填数字时,必须保证这个格子原本是空的。我见过新手写while(true)循环随机挑格子,结果格子不空就死循环了。正确的做法是先扫一遍棋盘,把空格的下标收集到std::vector<std::pair<int,int>>里,再在这个vector里随机选一个。
胜负判定分两块。失败:棋盘满了,且任意相邻格子(上下左右)都没有相等的数字,此时游戏结束。成功:任意格子值达到2048。有一个细节我要提一下——“达成2048”之后,主流实现是弹出胜利提示,但玩家仍然可以继续玩下去。所以isGameOver()和isWin()要分开写,别混在一起。胜利只是弹窗提示,不要直接锁死棋盘。
3. 界面绘制与交互响应
3.1 界面绘制方案选型
我第一次做这个项目的时候,在界面方案上纠结了好一阵。有两个选择:一是用16个QLabel摆成一个4x4的网格,移动后逐个更新label的文本和背景色;二是用一个QWidget,重写paintEvent(),在事件回调里用QPainter自己画。
16个QLabel方案的好处是代码好写—setText()一下、setStyleSheet()一下就成了,新手完全hold住。缺点是格子多的时候界面刷新不太平滑,而且页面状态一变就得写一堆findChild、setProperty的代码,后期越改越乱。
我最终用的是自定义绘制方案:一个QWidget,重写paintEvent(),每次棋盘变化后调用update()触发重绘,格子用QPainter::drawRoundedRect()画圆角矩形,数字用drawText()画在矩形中央。这样布局代码和逻辑代码解耦,棋盘绘制只看数据,数据变了就重画,画面状态完全由数据驱动。新手可能觉得paintEvent很玄乎,但其实它的逻辑很简单:系统觉得窗口需要刷新时,就会调用这个函数,你在里面画的任何东西都会显示在界面上,仅此而已。
3.2 键盘事件的正确接法
2048的主要操作就是方向键。在QWidget里接方向键,最直接的方式是重写keyPressEvent():
void GameWidget::keyPressEvent(QKeyEvent *event) { bool moved = false; switch (event->key()) { case Qt::Key_Up: moved = board.moveUp(); break; case Qt::Key_Down: moved = board.moveDown(); break; case Qt::Key_Left: moved = board.moveLeft(); break; case Qt::Key_Right: moved = board.moveRight(); break; default: QWidget::keyPressEvent(event); return; } ... }这里有一个特别常见的坑:键盘事件不响应。新手经常会遇到按方向键没任何反应,或者点击窗口内的按钮后方向键失灵。原因很简单——QWidget默认的焦点策略是Qt::NoFocus,也就是说窗口压根不接收键盘焦点。解决方法是在构造函数里加上一行:
setFocusPolicy(Qt::StrongFocus);Qt::StrongFocus表示该控件能通过点击和Tab键获得键盘焦点。加了这一行,方向键就正常了。我在这一步上卡了整整一个晚上,查了一圈才弄明白是焦点的问题,希望看到这里的你直接少踩这个坑。
3.3 视觉细节与数字配色
2048这个游戏,颜值也是游戏性的一部分。至少要做到数字越大颜色越深,空格是浅色底,4和2的颜色要有区分,数值上到128、256之后颜色要明显不一样。用Qt的样式表(QSS)或者直接在paintEvent里根据数值返回颜色都可以:
QColor GameWidget::colorForValue(int value) { switch (value) { case 0: return QColor(205, 193, 180); case 2: return QColor(238, 228, 218); case 4: return QColor(237, 224, 200); case 8: return QColor(242, 177, 121); case 16: return QColor(245, 149, 99); case 32: return QColor(246, 124, 95); case 64: return QColor(246, 94, 59); case 128: return QColor(237, 207, 114); default: return QColor(237, 204, 97); } }这个是2048官方的经典配色,照着抄就行。大数字的字体颜色用白色,小数字(2和4)用深色,这也是官方做法,你可以根据自己的审美调整,但建议不要偏离太多,因为玩家对2048的视觉风格有天然期待。
还有一个隐藏的小细节:如果你用QPainter绘制,关闭抗锯齿会让圆角矩形的边缘有锯齿感,打开抗锯齿即painter.setRenderHint(QPainter::Antialiasing),画面会平滑很多。这个不是必须,但属于“做了体验瞬间提升”的一行代码。
4. 打包发布与依赖处理
4.1 windeployqt的标准打包流程
项目写完,代码跑通了,但你的朋友双击那个exe文件时,如果Qt运行库没跟着一起走,程序会直接报错。Windows下Qt程序的发布方式和Linux完全不同,它不会静静地把所有依赖收集好,而是需要你手动执行一个叫windeployqt的工具。
打包之前先做一件非常重要的事:把编译模式从Debug切换到Release。Debug模式编译出来的exe体积巨大,而且依赖一堆调试用的DLL,不适合分发。切换方法是Qt Creator左下角的“电脑图标”那里,选择Release,然后重新构建。
之后找到编译生成的exe文件,路径通常在build-项目名-Desktop_Qt_5_15_2_MSVC2019_64bit-Release\release\项目名.exe。手动新建一个干净文件夹,把exe复制进去,然后打开命令行,进入这个文件夹,执行:
windeployqt 2048qt.exewindeployqt会自动扫描exe依赖了哪些Qt模块,然后把对应的DLL、插件目录、翻译文件全部复制到exe旁边。执行完成后,文件夹里会多出platforms目录、一堆.dll文件、还有几个子目录。这时双击exe就能正常运行了。
4.2 “no qt platform plugin could be initialized”排查
这个报错,写了这个项目的同学几乎100%会遇到一次,而且它出现在打包之后双击exe的那一刻。报错文本大概是windows no qt platform plugin could be initialized reinstalling the application,字面意思是“找不到Qt平台插件”。
之所以会出这个问题,最常见的原因是platforms目录缺失、位置不对,或者qwindows.dll没有随目录一起复制过去。Qt的Windows程序启动时,一定要去exe同级的platforms目录下加载qwindows.dll,这个插件是Qt和Windows窗口系统之间的桥梁。就算你把所有DLL都放在exe旁边,只要platforms目录不对,程序照样启动不了。
排查步骤很简单:先看打包文件夹里有没有platforms\qwindows.dll,如果这个文件不存在,说明windeployqt执行失败了(某些原因),自己从Qt安装目录C:\Qt\5.15.2\msvc2019_64\plugins\platforms里手动复制一份过来也能救活。
还有一个特别容易忽略的坑:exe路径中不能有中文和特殊字符。很多人打包时喜欢把文件夹命名为“2048游戏最终版”,结果双击直接无反应。Qt内部处理文件路径时对中文兼容性不太好,尽量把发布文件夹改成纯英文路径,能省掉一堆莫名其妙的兼容性问题。
4.3 发布时的依赖选择与体积控制
windeployqt默认会把调试用的DLL也复制过来?不会,但如果你是从Debug目录下执行,那复制的就是Debug版DLL,程序跑起来速度慢、体积也大。所以再次强调,一定确认是Release版本再打包。
另外一个体积压缩技巧:Qt 5.15.2一个空窗口程序打包出来大概在20MB到50MB之间,这是Qt框架本身的开销,属于正常范围。如果你想进一步压缩,可以尝试在pro文件里加上CONFIG += release,然后排除不需要的模块。但实际上对于发给朋友的玩具项目来说,30MB的exe完全在可接受范围内,没必要为了压缩体积引入UPX之类的工具把自己搞晕。
还有一件事容易被忽略:如果你用的是MSVC编译器,那发布包需要带上Microsoft Visual C++ Redistributable运行库。目标电脑没装过Visual C++的话,会提示“缺少VCRUNTIME140.dll”之类的错误。解决方案有两种:一是让朋友去微软官网装一下,二是自己写一个安装包脚本,把运行库和exe一起打进去。MinGW版本的Qt就没有这个问题,但相应地它需要带上libgcc_s_seh-1.dll、libstdc++-6.dll和libwinpthread-1.dll这些MinGW运行库,windeployqt会把它们一起复制出来,所以不用额外操心。
5. 常见问题与调试实录
5.1 典型报错速查表
我把自己写这个项目过程中遇到、以及帮朋友排查过的典型问题整理成了一张表,建议直接存下来,遇到了对照排查:
| 现象 | 原因 | 解决方案 |
|---|---|---|
| 按方向键没反应 | 窗口没有获取键盘焦点 | 构造函数加setFocusPolicy(Qt::StrongFocus) |
| 游戏启动时随机数字序列总是重复 | 随机种子未初始化 | 用QRandomGenerator::global()替换rand() |
| 合并方向反了(向上按成了向左) | 旋转方向搞错 | 先用纸画一下旋转前后的坐标映射,再写rotate函数 |
| 2048之后还能继续合并但不再弹提示 | 胜利判定只判一次 | 在mainwindow里用bool记录是否已提示过 |
| 打包后exe双击无反应或报平台插件错误 | platforms目录缺失或路径含中文 | 重新执行windeployqt,或手动复制platforms目录;改用纯英文路径 |
| 界面出现残影或重叠 | 没有清除之前的绘制内容 | paintEvent里先调用QPainter::fillRect画背景清屏 |
5.2 调试技巧:用日志代替反复试错
新手最容易犯的错误是程序跑出错误结果后,频繁加qDebug()调试,然后一遍遍重开游戏去操作。这样既费时间又容易搞混状态。我推荐一个自己一直在用的方法:给GameBoard逻辑类写一个printBoard()函数,在移动操作前后分别调用一下,看看数据是否按预期变化:
void GameBoard::printBoard() { for (int row = 0; row < BOARD_SIZE; ++row) { for (int col = 0; col < BOARD_SIZE; ++col) { qDebug() << board[row][col] << "\t"; } qDebug() << "\n"; } }把逻辑层和界面层分离之后,你甚至不需要启动界面,直接在main里测试:
GameBoard board; board.init(); // 生成两个初始数字 board.printBoard(); board.moveLeft(); board.printBoard();这样测出来的结果只和数据有关,不会受到键盘事件、焦点、绘制等乱七八糟的因素干扰。等逻辑完全正确了,再接上界面,就能保证界面这层出问题的概率降到最低。这就是分层架构在调试上带来的巨大优势,也是我在这个项目里最想让读者真正体会到的东西。
5.3 最容易忽略的边界情况
2048还有一个很隐蔽的边界情况:一行或多个方向都没有有效移动时,不应该生成新数字。比如整个棋盘只有第1行有数字,玩家按了下键,数组没有任何变化,这时候不应该生成新块。前面讲过的moveLeft()返回bool在这里就派上用场了。四个方向统一处理时,用moved ||= board.moveXXX()来做合并标志,只有moved == true才走“生成新数字”的流程。
还有游戏结束判定。很多初学版本只判断了“格子全满”,忘记了判断“相邻格子是否还能合并”。比如棋盘全满但还有两个相邻的256可以合并,那游戏其实还没结束。isGameOver()必须同时检查这两条,一个条件不满足就说明还没结束。这个逻辑写不对,很容易出现游戏提前结束的乌龙情况。
5.4 新手的几个进阶方向
如果你这个项目已经跑通了,想再往上走一步,我推荐按下面的顺序去扩展,难度递增,但每步都能学到新东西:
- 动画过渡:目前移动是瞬间完成的,可以给每个数字块加一个缓慢滑动的动画,用
QPropertyAnimation做。这里能学到Qt动画框架的基本用法,也是让游戏从“能用”变“好玩”的关键一步。 - 分数排名:用
QSettings把最高分保存到系统注册表或配置文件里,下次启动还能看到历史最高分。这一步能学会Qt的本地持久化存储。 - 撤销功能:用
std::stack保存每次移动前的棋盘快照,按快捷键撤销上一步。栈这种数据结构在教科书上看了N遍,终于有了一个实际应用场景。 - 扩展棋盘到5x5或6x6:这需要把硬编码的
BOARD_SIZE改掉,重新审视所有算法的通用性,是很好的代码重构练习。
我个人在实际操作中的感受是,2048这个项目的天花板比想象中高很多。你可以在里面无限叠加功能,从动画到音效到联机排行榜,每一步都有对应的技术点可以学。而它最宝贵的地方在于——因为核心逻辑已经跑通了,后面加功能都是在舒适区边缘试探,不会让人产生“从零开始学”的挫败感。
最后一个分享给大家的小技巧:维护一个“版本完成度”清单,每完成一个功能就记录下来,比如“v0.1 核心逻辑完成”“v0.2 界面绘制完成”“v0.3 动画完成”。这样做的好处有三个,一是训练自己拆解需求的能力,二是日后想起来能看看自己的成长轨迹,三是写简历项目经验时不用临时回忆。对初学者来说,把项目拆碎、逐个击破,比一口气写完整个游戏重要得多。
本文还有配套的精品资源,点击获取