C++初学者用Qt开发2048小游戏:从算法到发布全流程

📅 发布时间:2026/9/9 21:13:15
C++初学者用Qt开发2048小游戏:从算法到发布全流程
简介基于C与Qt4实现的2048小游戏完整工程专门面向C初学者打造可帮助深入理解面向对象编程、事件处理与图形用户界面设计。项目采用GameBoard、Tile、GameManager三个核心类划分游戏逻辑棋盘类维护状态与滑动合并规则方块类承载数值与位置更新管理类统筹开局、计分与胜负判断完整实现滑动、随机生成、计分、胜负检测等玩法。Qt4的信号与槽机制在按键响应和界面更新中发挥关键作用玩家每次滑动都会触发对应槽函数执行逻辑配合QGraphicsView和QGraphicsItem实现自定义图元与动态效果。资源共六个文件含两个cpp源文件、一个h头文件、一个ui界面布局、一个pro工程文件及用户配置文件压缩包仅7KB结构精简适合逐行阅读。已有265人学习下载适合希望结合实战掌握C基础语法、Qt4信号槽机制与QGraphicsView场景绘制的初学者。研读该工程可同时收获类设计思路、事件响应流程和GUI调试技巧为后续游戏开发积累宝贵经验。 一个多月前我的C学习正好卡在一个尴尬的阶段语法都认识参考资料能看懂但真正动手写个像样的东西又不知道从哪下手。后来导师给我指了个方向做一个小游戏把基本语法、数据结构、事件处理、界面交互全串起来。我选了2048配上Qt框架做成了这个2048qt小游戏C初学.zip。做完之后回头看这个选择确实太合适了——2048规则简单但逻辑完整Qt又能帮你把C的面向对象、信号槽、事件循环这些核心机制全部过一遍。这篇文章把我从零到打包发布的完整过程、代码结构和踩过的坑都整理出来适合刚学完C基础、想找一个像样的练手项目、以及准备接触Qt的同学参考。1. 为什么第一门GUI课我选了2048而不是计算器先说说项目选型。很多人第一次学Qt做的是计算器、记事本、贪吃蛇。这几个我都短时间试过体会很明显计算器的界面逻辑太简单按钮点击和文本框显示十几分钟就能写完界面但核心业务逻辑几乎没有练不到C贪吃蛇有动画、碰撞检测和连续状态对刚上手的同学来说难度跳得有点猛光是QTimer驱动蛇身移动和键盘方向连续触发的细节就够喝一壶2048则刚好卡在中间——它的游戏逻辑足够完整滑动、合并、生成随机方块、胜负判定但又不是特别复杂C标准库和Qt的GUI能力都能覆盖到。从学习路径上看2048有四个硬核点正好对应C初学阶段的重点状态管理4x4的棋盘本质上是一个二维数组每次移动都是对数组的一次批量操作涉及遍历、条件判断和元素交换。算法练习每个方向的移动和合并考的是“压缩—合并—再压缩”的逻辑这是很典型的数组处理思维。界面交互键盘方向键触发动作、界面上16个格子的实时刷新天然适合用Qt的信号槽和重绘机制实现。完整闭环从建模、算法、UI到打包发布一个项目把所有环节串起来。而且——2048的界面有很明确的标准配色2是浅黄4是浅橙8是橙色像这种不需要你花心思设计照着经典配色抄就能做得很好看。对于初学者来说视觉反馈明确做完有成就感这件事比什么都重要。2. 核心算法解析2048的移动合并到底在做什么如果说Qt是2048项目的皮那移动合并算法就是骨骼。这部分我没用任何Qt API纯C实现写在一个独立的GameModel类里。这么做的好处是逻辑容易测试也可以在控制台先跑一边确认没问题再套界面。2.1 数据结构怎么选我用的是一维数组模拟二维棋盘长度16class GameModel : public QObject { Q_OBJECT public: explicit GameModel(QObject* parent nullptr); void startNewGame(); bool move(int direction); // 0上 1下 2左 3右 bool isGameOver() const; int getTile(int row, int col) const { return board[row * 4 col]; } int getScore() const { return score; } signals: void boardChanged(); void gameOver(); void gameWin(); private: int board[16]; int score 0; };一维数组加row * 4 col的下标换算比std::vectorstd::vectorint更直观内存也是连续的一块遍历时更顺手。新手阶段我建议就用这种最简单的结构别一上来套智能指针、封装容器之类的东西。2.2 向左移动的完整逻辑移动合并这件事核心是三步压缩去掉0→ 合并相邻相等→ 再压缩。以左移为例每一行的处理逻辑void GameModel::moveRowLeft(int row) { // 1. 把这一行的非零数字依次取出相当于“压缩” int tmp[4] {0}; int idx 0; for (int c 0; c 4; c) { if (board[row * 4 c] ! 0) { tmp[idx] board[row * 4 c]; } } // 2. 从左到右合并相邻相同的数字 for (int c 0; c 3; c) { if (tmp[c] ! 0 tmp[c] tmp[c 1]) { tmp[c] * 2; score tmp[c]; // 后面的数字前移一位然后跳过下一个位置防止重复合并 for (int k c 1; k 3; k) { tmp[k] tmp[k 1]; } tmp[3] 0; } } // 3. 写回棋盘 for (int c 0; c 4; c) { board[row * 4 c] tmp[c]; } }这里有一个关键点合并时要跳过下一个位置。比如一行是“2 2 2 2”按2048的规则应该合并成“4 4 0 0”而不是“4 0 0 4”或者“8 0 0 0”。因为合并完成后我执行了for (int k c 1; ...)把后续元素前移原本tmp[c1]位置的值已经变成更后面的数而c在下一轮循环里会自增我再手动c一下就能跳过已经被处理过的元素避免一个格子参与了两次合并。这个细节我第一次做的时候就没考虑结果出现了连续合并成巨量数字的离谱结果。2.3 其他三个方向怎么复用左右上下四个方向如果每个方向都写一套压缩合并代码会极其冗余。我的做法是把其他方向先“旋转”到左边处理处理完再旋转回来。具体实现也不复杂写一个旋转90度的函数把棋盘顺时针旋转执行左移再旋转回来。比如想处理“上移”就把棋盘顺时针旋转三次相当于逆时针一次左移之后再顺时针旋转一次恢复原样。核心是次数要对上移棋盘逆时针旋转1次 → 执行左移 → 顺时针旋转1次下移棋盘逆时针旋转3次 → 执行左移 → 顺时针旋转3次右移棋盘逆时针旋转2次 → 执行左移 → 顺时针旋转2次旋转一次的实现void GameModel::rotateClockwise() { int tmp[16]; for (int r 0; r 4; r) { for (int c 0; c 4; c) { tmp[c * 4 (3 - r)] board[r * 4 c]; } } memcpy(board, tmp, sizeof(board)); }这样整个移动模块只需要维护一个moveRowLeft其他方向全是旋转和转置的组合。我当时写完这个设计之后心里还是挺爽的——这就是一次典型的数据抽象练习代码量少了很多逻辑也更清晰。2.4 随机方块生成与胜负判定每次有效移动之后需要在空白格子里生成一个新的数字方块绝大多数情况是2小概率是4。用C的随机数可以这样写void GameModel::spawnRandomTile() { std::vectorint emptyCells; for (int i 0; i 16; i) { if (board[i] 0) emptyCells.push_back(i); } if (emptyCells.empty()) return; int idx emptyCells[rand() % emptyCells.size()]; board[idx] (rand() % 10 0) ? 4 : 2; // 10%概率出现4 }这里有个需要注意的地方如果你是Unix系环境或者老编译器记得在初始化时srand(time(nullptr))不然每次启动游戏随机序列都一样。Qt里也可以用QRandomGenerator::global()-bounded(...)效果等价看个人习惯。游戏结束的判定是个容易漏的细节——不是棋盘满了就结束而是要确认没有任何相邻格子相等也就是不存在可合并操作。我实现的判断逻辑是对整个棋盘扫描bool GameModel::hasMoves() const { // 1. 有空位说明肯定还能走 for (int i 0; i 16; i) if (board[i] 0) return true; // 2. 横向相邻相等说明还能合并 for (int r 0; r 4; r) for (int c 0; c 3; c) if (board[r * 4 c] board[r * 4 c 1]) return true; // 3. 纵向相邻相等说明还能合并 for (int r 0; r 3; r) for (int c 0; c 4; c) if (board[r * 4 c] board[(r 1) * 4 c]) return true; return false; }区块满、合并没戏这两种情况同时满足才触发gameOver信号。我当时写的时候只判断了“是否还有空格”导致棋盘填满了数字但明明还能合并的时候游戏提前结束挺尴尬的一个低级bug。3. Qt界面搭建模型与视图的分离是正路算法模块在控制台验证通过后我开始做图形界面。这里要特别强调一个学习观念不要把所有代码都塞进MainWindow类里。网格布局的创建、键盘的响应、格子的样式看起来很UI但如果和游戏逻辑混在一起后面每改一个功能都要翻几百行代码。我最后是分成两个类GameModel负责数据和规则MainWindow负责展示和交互。3.1 界面组成结构经典2048界面主要包含顶部分数显示4x4的网格区域游戏结束/获胜的遮罩层我用QGridLayout放16个格子每个格子是一个QFrame里面放一个居中的QLabel显示数字。为什么不直接用QPushButton因为它们不需要处理点击事件用纯展示控件更合适而且QFrame自带的setStyleSheet很方便设置边框圆角和背景色。MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent), model(new GameModel(this)) { QWidget* central new QWidget(this); setCentralWidget(central); QVBoxLayout* mainLayout new QVBoxLayout(central); // 分数区 scoreLabel new QLabel(0, central); scoreLabel-setAlignment(Qt::AlignCenter); mainLayout-addWidget(scoreLabel); // 网格容器 QGridLayout* gridLayout new QGridLayout; for (int i 0; i 16; i) { QFrame* cell new QFrame(central); cell-setObjectName(cell); QLabel* valueLabel new QLabel(, cell); valueLabel-setAlignment(Qt::AlignCenter); gridLayout-addWidget(cell, i / 4, i % 4); cells[i] cell; cellLabels[i] valueLabel; } mainLayout-addLayout(gridLayout); connect(model, GameModel::boardChanged, this, MainWindow::updateBoard); connect(model, GameModel::gameOver, this, MainWindow::showGameOver); connect(model, GameModel::gameWin, this, MainWindow::showWin); }有个容易忽略的坑QFrame里加QLabel之后需要给QFrame设置一个布局或者手动把QLabel设为QFrame的子控件并设置几何位置。我图省事直接用cell-setGeometry(...)又觉得太死最后给每个cell加了一个QVBoxLayout再把QLabel放进去。这样格子大小改变时QLabel会自动居中不会乱跑。3.2 键盘事件与信号槽联动2048的交互主要集中在键盘上。Qt中处理键盘事件有两种方式重写keyPressEvent或者用QShortcut。前者更直观也能更好地控制事件优先级。void MainWindow::keyPressEvent(QKeyEvent* event) { bool handled true; switch (event-key()) { case Qt::Key_W: case Qt::Key_Up: model-move(0); break; case Qt::Key_S: case Qt::Key_Down: model-move(1); break; case Qt::Key_A: case Qt::Key_Left: model-move(2); break; case Qt::Key_D: case Qt::Key_Right: model-move(3); break; default: handled false; } if (handled) { event-accept(); if (model-isGameOver()) { QMessageBox::information(this, 游戏结束, 棋盘满了); } } else { QMainWindow::keyPressEvent(event); } }这里有个新手容易忽略的操作需要让窗口获取焦点才能收到键盘事件。如果在主窗口里放了其他控件焦点可能跑到某个QFrame或者QLabel上此时键盘事件反而不会触发主窗口的keyPressEvent。解决方法是构造函数里写一句setFocusPolicy(Qt::StrongFocus)或者每次界面刷新后执行this-setFocus()否则你会发现键盘按了半天没反应但鼠标点击一切正常。4. QSS美化让C程序也能有精致外观Qt和网页CSS一样用QSSQt Style Sheets设置界面样式。经典2048的配色很有辨识度我整理了一份对照表数字背景颜色文字颜色说明0#CDC1B4—空格子透明度处理2#EEE4DA#776E65浅米色4#EDE0C8#776E65浅橙8#F2B179#F9F6F2橙色16#F59563#F9F6F2深橙32#F67C5F#F9F6F2珊瑚红64#F65E3B#F9F6F2朱红128#EDCF72#F9F6F2金黄256#EDCC61#F9F6F2亮金512#EDC850#F9F6F2深金1024#EDC53F#F9F6F2带光泽的金2048#EDC22E#F9F6F2终极金具体实现时我在updateBoard槽里动态更新每个格子的样式void MainWindow::updateBoard() { static const QString cellColors[] { #CDC1B4, #EEE4DA, #EDE0C8, #F2B179, #F59563, #F67C5F, #F65E3B, #EDCF72, #EDCC61, #EDC850, #EDC53F, #EDC22E, #3C3A32, #3C3A32, #3C3A32, #3C3A32 }; for (int i 0; i 16; i) { int value model-getTile(i / 4, i % 4); int colorIndex 0; while (value 0 (1 (colorIndex 1)) value) colorIndex; QString color cellColors[colorIndex]; cells[i]-setStyleSheet( QString(QFrame#cell { background-color: %1; border-radius: 8px; }) .arg(color) ); if (value 0) { cellLabels[i]-setText(QString::number(value)); cellLabels[i]-setStyleSheet( QString(QLabel { color: %1; font-size: 28px; font-weight: bold; }) .arg(value 4 ? #F9F6F2 : #776E65) ); } else { cellLabels[i]-setText(); } } scoreLabel-setText(QString(得分%1).arg(model-getScore())); this-setFocus(); }这里得说明一下colorIndex的计算逻辑。2048里的数字全是2^n指数的范围从1到11正好对应表格前11种颜色。我用了一个循环不断左移判断本质上是求log2(value)的底。如果数值超过2048就统一使用深色背景和白色文字这样即使你玩出了4096、8192界面也不会配色崩坏。5. 构建与发布windeployqt 和 platform plugin 报错解决做完了功能到我以为最没有技术含量的环节把程序交给别人运行。结果这一环踩的坑比整个开发过程都多而且都是C初学者必然会遇到的。5.1 Release版本和Debug版本的区别我第一次打开发布想法时直接用Qt Creator的构建按钮默认Debug模式生成了exe拷到别的电脑上一运行提示缺少一堆DLL。Debug版本带了一大堆调试符号和依赖库体积大且很依赖开发环境发布给用户用的必须是Release版本。在Qt Creator界面右下角把构建方式从Debug切换到Release然后重新构建工程到构建目录的release文件夹里找到2048.exe。这个exe单文件不能直接拷走需要把必要依赖放在它旁边。5.2 windeployqt 的正确用法Qt官方提供了一个工具叫windeployqt专门干这件事扫描exe依赖的Qt模块自动拷贝DLL和插件到exe目录。用法不是双击而是在命令行执行cd /d D:\build-2048-Desktop_Qt_5_15_2_MinGW_64_bit-Release\release D:\Qt\5.15.2\mingw81_64\bin\windeployqt.exe 2048.exe执行完之后release目录下会多出platforms目录、一堆Qt5*.dll和styles、imageformats之类的插件目录。此时把整个release目录压缩发给别人基本就能跑了。我在这里反复出现一个报错给哥们发过去之后他运行弹窗提示windows no qt platform plugin could be initialized reinstalling the applicat...意思是没有可用的平台插件。这个报错的头号原因exe旁边缺platforms/qwindows.dll。qwindows.dll是Qt在Windows下和系统交互的底层插件没有它Qt程序连窗口都创建不了。如果你确认windeployqt已经执行过、文件也在就要检查路径是否正确platforms目录必须和exe在同一级目录下不能嵌在子目录里。第二个常见原因Qt版本不同跑得动程序的运行库版本不匹配。比如MinGW版本的Qt程序你给它拷贝MSVC版本的Qt DLL照样报同样的错。所以一定要搞清楚你的套件类型是MinGW还是MSVC判断依据很简单用Qt Creator配置构建套件时那个名字里写得很清楚。第三个原因缺少VC运行库Microsoft Visual C Redistributable。如果你用MSVC编译套件生成的程序目标电脑上需要安装对应版本的VC运行库才能运行。最稳妥的做法是申请一个“VC_redist.x64.exe”放进安装包里让用户先装它再运行游戏。我当时为了验证是不是缺运行库特意在一台纯净Windows虚拟机上做了测试一步步排查才确定到最终就是缺platforms目录里的插件拷进去立刻就好了。后来我把这套发布流程整理成了自己的固定步骤构建Release版本检查release目录确认exe独立存在运行windeployqt路径不能有空格运行exe测试一次把整个目录压缩、改名、外发6. 新手最容易踩的C陷阱与代码健壮性优化2048项目虽小但五脏俱全。开发过程中我踩了不少C相关的坑说几个有代表性的大家写的时候可以避一避。第一个坑数组越界。2048的棋盘是4x4我一开始遍历时没留神循环边界写成4结果在访问board[r * 4 c 1]这一类相邻元素时当c 3越界访问了。C的数组越界不会像Java那样抛异常它会悄悄读出内存里相邻位置的值然后你的棋盘莫名其妙出现一个“8”你怎么查都查不到原因。定位这个问题的办法是用Qt的Q_ASSERT或者assert宏在调试模式下把越界行为拦截下来。第二个坑lambda捕获列表忘记拷this。如果你用lambda做定时器回调或者按钮事件响应里面要访问模型成员和方法必须显式捕获this。比如QTimer::singleShot(200, [this]() { spawnAnimation(); });我一开始写的lambda没有[this]编译不报错运行时直接崩溃因为访问了空指针。这个坑在Qt的信号槽场景里太常见了记住规则lambda体里只要用到外部类的任何成员就必须把this写进捕获列表。第三个坑随机数种子。如果每次启动游戏生成的第一个随机方块都一样十有八九是没有srand。我在前面已经贴过解决方案这里再强调一次rand()是伪随机序列不重新播种的话每次程序的随机序列完全相同这对游戏的体验影响很大。第四个坑内存管理。Qt的父对象机制很友好new出来的控件只要指定了父亲销毁窗口时Qt会自动回收子对象。但如果你用了new之后忘了指定父对象或者写了delete又没有置空指针野指针就会在某个你不注意的地方冒出来。我的建议是所有控件类对象都指定parent参数所有业务对象放栈上或者用智能指针尽量不手动delete。代码层面还有一个优化点值得做检查“是否有效移动”。如果用户按了方向键但棋盘没有任何变化比如已经全部靠左还按左就不要生成新方块。判断方法很简单在move函数开头记录旧棋盘执行合并后再比较是否发生变化。bool GameModel::move(int direction) { int oldBoard[16]; memcpy(oldBoard, board, sizeof(board)); int oldScore score; // 旋转、左移、旋转恢复…… rotateAndMove(direction); bool changed (memcmp(oldBoard, board, sizeof(board)) ! 0) || (score ! oldScore); if (changed) { spawnRandomTile(); emit boardChanged(); checkStatus(); } return changed; }这样处理后按键不会“无中生有”刷出方块游戏的节奏也更正常。关于项目进一步扩展的方向现在这个版本已经能完整玩了但离经典2048体验还是有差距。如果学有余力有几个方向值得继续做第一加入滑动动画。现阶段16个格子是瞬间更新状态的玩起来不够丝滑。用QPropertyAnimation配合格子坐标变化可以让合并有“滑动淡入淡出”效果这部分涉及Qt动画框架很能锻炼人。第二加入AI自动求解。我研究过一阵2048的AI算法核心思路是用“期望值最大”策略模拟所有可能的移动对每个格子之后随机生成2/4的情况计算期望收益最后选期望最高的方向。这属于算法进阶方向但做完之后对C的STL容器和搜索框架的理解会突飞猛进。第三自定义棋盘大小和成就系统。改成5x5、6x6棋盘、增加最高分持久化存储用QSettings等都是比较实用的小功能。回想这个项目的完整周期我最深的体会是先写控制台版本验证算法再接Qt界面这个顺序能省一半的调试时间。如果你只想着早点看到图形界面把逻辑和UI写在一起遇到问题的时候会发现分不清是算法错了还是界面刷新错了。先用纯C把move、merge、gameOver这些函数跑通再花一个晚上套上外观整个过程会顺畅得多。本文还有配套的精品资源点击获取