
简介一份基于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::vectorstd::vector 这样更符合C习惯而且代码可读性高很多。这里没有用QVector是因为我希望让逻辑层尽量不依赖Qt保持纯C风格。如果你以后想把逻辑测试单独跑在命令行里甚至专门写单元测试这样的设计会方便得多。棋盘的初始状态是4x4我用一个构造函数给board_赋值board_.resize(4, std::vectorint(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::vectorint 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::vectorint 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::vectorstd::pairint, 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 c11 SOURCES \ main.cpp \ mainwindow.cpp \ board.cpp HEADERS \ mainwindow.h \ board.h在实际开发中你不需要手动编辑.pro里的每一行Qt Creator会在添加文件时自动更新。不过你要留意CONFIG c11或者c17因为某些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里而主窗口没收到事件。本项目的QGridLayoutQLabel方案不会出现这种问题但如果以后你做更复杂的窗体会遇到。另一种情况是键盘事件被重复触发按下没抬起但事件不断发送。这是因为没有处理自动重复。你可以通过判断event-isAutoRepeat()来忽略按住不放的自动重复事件。这个细节会在调试时体现出专业度。5.3 界面上的数字没有实时更新如果你移动成功但界面没有变化很可能是移动函数返回的changed没有正确控制刷新。或者你在更新时只更新了文字忘记更新样式表。我建议把所有界面刷新统一封装到一个updateBoard()函数里每次都完整地更新文本和颜色避免不一致。另一种可能是在board_的copy过程中出问题。比如你传参时用的是临时副本移动操作没有作用在真正的成员变量上。检查一下函数签名确保std::vectorstd::vectorint而不是std::vectorstd::vectorint值传递。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走向现代QtQt除了QWidgets还有一套QML语言适合做更漂亮的界面。2048这种格子型游戏用QML写其实更自然你甚至可以用Repeater快速生成16个格子动画也内置支持。如果你想了解Qt的现代开发方式把这个项目用QML重写一遍是个不错的练习。6.4 真正理解算法从2048到更广的棋盘游戏2048的移动算法本质上是二维数组的状态变换。理解了压缩-合并-再压缩这套思路以后你再写消消乐、数独或者其他棋盘游戏时处理行列的逻辑会变得更顺手。我甚至见过有人用2048来练手强化学习算法那就完全超出C初学范围了。如果你也打算拿这个项目练手我的建议是先不看任何现成代码自己把移动函数写出来跑通之后再对照其他人的实现优化。这个过程里犯的错比看十遍教程都有价值。等你把整个项目从控制台版本做到Qt界面再打包发给朋友玩那种成就感就是最好的学习动力。本文还有配套的精品资源点击获取