C++初学必看:用Qt4实现2048小游戏的完整源码解析 简介面向C初学者的一款Qt4版2048小游戏完整源码包用于练习面向对象编程、事件处理与GUI设计。项目将游戏逻辑拆分为GameBoard、Tile、GameManager三个核心类GameBoard负责棋盘状态与合并规则Tile代表数字方块并实现自身数值更新GameManager管理游戏流程、得分与结束判断界面层基于Qt4的QGraphicsView与QGraphicsItem绘制棋盘配合信号槽机制响应滑动按键覆盖从棋盘初始化、移动合并到胜负判定的完整流程。包体仅6个文件含2个cpp源码、1个头文件、1个Qt Designer界面ui文件、1个pro工程文件和1个用户配置文件压缩后约7KB结构精简其中ui文件与代码分离便于理解界面搭建和业务逻辑的衔接方式很适合直接对照学习或在此基础上修改扩展。已有266人在线学习下载对刚入门C图形编程的读者而言这个小项目能帮助理解封装、继承、算法拆分及Qt4基础组件使用方法同时也可作为课程设计或自学练手的入门实例。1. 2048qt小游戏C初学阶段最值得拆的一份Qt4源码C初学最尴尬的处境是语法都认识但写出来的程序永远只有一个黑色控制台窗口。这份2048qt小游戏正好卡在进阶的节骨眼上它用Qt4做了一套完整的图形界面4x4棋盘、方向键合并、计分、游戏结束判断全都跑在一个可编译的工程里。对我这种习惯边跑边改的人来说它最大的价值不是“又多了一个2048”而是把算法和界面拆成了两个清晰的部分——棋盘逻辑不依赖窗口窗口只负责画和收按键。如果你正在挑图形界面课设的参考或者想研究QPainter怎么把数字变成一块块圆角格子这份源码比那些几百行的练习项目合适得多。2. 工程骨架拆解从.pro文件到main.cpp的启动链路2.1 为什么是Qt4而不是新版本现在很多教材和课程还在讲Qt4原因无非是老工程多、资料全、老师写过。Qt4和Qt5之间最大的分水岭是模块拆分Qt4时代所有的界面控件都在QtGui模块里到了Qt5就把窗口部件拆成了独立的QtWidgets模块。所以打开这份工程你会看到.pro文件里写的还是老式写法# 2048qt.pro QT core gui TARGET 2048qt TEMPLATE app SOURCES main.cpp \ gameboard.cpp HEADERS gameboard.h \ gameboard.h写在TARGET名后面的是最终生产的exe文件名字。如果用的是Qt5直接打开这个工程会报一堆“找不到QWidget”的错误因为新版没有自动把widgets模块牵进来。常见做法是在.pro里补一行QT widgets就能编过。这个知识点在迁移老工程时几乎必踩知道QT行是干嘛的比死记版本号更值。TEMPLATE app说明这是一个可执行程序而不是库。如果哪天你想把棋盘逻辑抽成静态库给别的项目用把它改成TEMPLATE lib就行——这是后话初学阶段不用动。2.2 目录结构一个类管算法一个类管界面解压后工程很小核心文件就五个左右2048qt/ ├── 2048qt.pro ├── main.cpp ├── gameboard.h ├── gameboard.cpp └── README.txt没有ui文件所有界面都是代码画的这点对新手友好不用去折腾Qt Designer生成的XML。gameboard.h和gameboard.cpp里是一个继承自QWidget的GameBoard类棋盘数据、移动算法、绘制、按键全部集中在这个类里。从架构角度看这种写法有点“大锅烩”但对一个三百行级别的练手项目过度拆分反而增加理解成本。2.3 main.cpp窗口是怎么被拉起来的入口文件非常朴素#include QApplication #include gameboard.h int main(int argc, char *argv[]) { QApplication a(argc, argv); GameBoard w; w.setFixedSize(420, 540); w.setWindowTitle(QString::fromUtf8(2048)); w.show(); return a.exec(); }QApplication负责管理事件循环a.exec()这一行会阻塞住直到用户关闭窗口。setFixedSize固定了窗口尺寸防止拖拽改变大小后棋盘跟着变形。注意QString::fromUtf8(2048)这个写法——Qt4默认用本地编码解析普通字符串如果你的源码文件是UTF-8保存的直接传2048在中文Windows上可能显示成乱码。fromUtf8是显式告诉编译器“这个字符串是UTF-8帮我转成内部Unicode”老工程里常见这种细节。QApplication对象必须创建在窗口之前顺序反了会直接崩溃因为QWidget的构造函数依赖QApplication已经存在的事件循环设置。这个顺序问题我见过好几次新手尤其容易在拆分多个文件时把new QApplication写错位置。2.4 类的职责划分游戏状态放哪、绘制放哪看头文件能直接摸清设计思路class GameBoard : public QWidget { Q_OBJECT public: explicit GameBoard(QWidget *parent 0); protected: void paintEvent(QPaintEvent *event); private: int m_grid[4][4]; int m_score; bool m_gameOver; void restart(); bool moveLeft(); bool moveRight(); bool moveUp(); bool moveDown(); void spawnTile(); bool isGameOver() const; };m_grid是唯一的棋盘数据源所有算法函数读它、改它界面只是把它的内容画出来。这种“数据与绘制分离”的思路在很多小游戏里都适用先保证算法正确再考虑画出来好不好看。Q_OBJECT宏必须写在类的最前面它让Qt的元对象系统能处理信号槽如果你的类里没有信号槽删掉也能编译但建议保留因为稍微加个信号就要用它。3. 核心算法拆解2048的合并、移动与胜负判定3.1 数据和初始化一个4×4数组就是全部棋盘的本质是一个4×4的二维数组// gameboard.cpp 构造函数里的初始化 GameBoard::GameBoard(QWidget *parent) : QWidget(parent), m_score(0), m_gameOver(false) { setFocusPolicy(Qt::StrongFocus); restart(); } void GameBoard::restart() { memset(m_grid, 0, sizeof(m_grid)); m_score 0; m_gameOver false; // 开局先放两个数字 spawnTile(); spawnTile(); update(); }memset(m_grid, 0, sizeof(m_grid))把整个数组清零sizeof(m_grid)是16个int的总字节数不是数组长度这个细节写错就会只清前几个元素。开局放两个数是2048的规矩保证玩家一开始就有得合并。值得注意的是构造函数里的setFocusPolicy(Qt::StrongFocus)这句是键盘能响应的前提。QWidget默认不接受键盘焦点不加这行窗口显示出来后按方向键一点反应都不会有。第二耳朵的坑在第5章再细说。3.2 向左合并压缩、合并、再压缩核心是moveLeft()其他的方向最终都绕它转bool GameBoard::moveLeft() { bool moved false; for (int row 0; row 4; row) { // 第一步把这一行非零数字收集到临时数组 int line[4] {0}; int idx 0; for (int col 0; col 4; col) { if (m_grid[row][col] ! 0) { line[idx] m_grid[row][col]; } } // 第二步相邻相同数字合并只能合一次 for (int i 0; i 3; i) { if (line[i] ! 0 line[i] line[i 1]) { line[i] * 2; m_score line[i]; // 把后面的数字整体左移一格 for (int j i 1; j 3; j) { line[j] line[j 1]; } line[3] 0; } } // 第三步写回棋盘同时判断这行有没有变化 for (int col 0; col 4; col) { if (m_grid[row][col] ! line[col]) { m_grid[row][col] line[col]; moved true; } } } return moved; }整个流程分成三段压缩、合并、再压缩。第一步把[0, 2, 0, 2]变成line [2, 2, 0, 0]把空洞全部挤到右边。第二步从左向右扫描line[0] line[1]就合并成4然后循环里的内层for把后面的数字往左挪。第三步把临时数组写回去同时记录有没有发生位置变化返回值决定后续要不要生成新数字。这里一定要说清楚一个初学容易翻车的规则2048不允许“连消”。比如一行是[2, 2, 2, 2]合并一次后变成line [4, 4, 0, 0]然后i继续往后走第二个4因为右边是0所以不会和左边那个4再合一次。最终结果两个4而不是一个8。如果你贪心写成“只要相邻相等就递归合并”[2,2,2,2]会一次性变成8整个游戏的难度和手感就全歪了。3.3 四个方向统一处理转置和反转上、下、右三个方向不需要各写一份合并逻辑常见做法是转换坐标。比如向右移动可以先把该行反转然后调用向左的合并再反转回来bool GameBoard::moveRight() { bool moved false; for (int row 0; row 4; row) { // 反转当前行 for (int i 0, j 3; i j; i, --j) { int t m_grid[row][i]; m_grid[row][i] m_grid[row][j]; m_grid[row][j] t; } } moved moveLeft(); // 合并完之后再反转回来 for (int row 0; row 4; row) { for (int i 0, j 3; i j; i, --j) { int t m_grid[row][i]; m_grid[row][i] m_grid[row][j]; m_grid[row][j] t; } } return moved; }向上移动则是把每一列抽出来当成一行处理。更省事的方法是统一维护一个“当前方向”的枚举在读写数组时做一层坐标映射但那种写法对初学者不友好。这份源码采用的是各方向独立函数、内部复用moveLeft的思路代价是代码重复好处是单步调试时一目了然每一步棋盘长什么样都能跟踪到。3.4 随机生成新数字和游戏结束判断每次移动成功要往空位里放一个新数字标准规则是90%概率出210%概率出4void GameBoard::spawnTile() { // 收集所有空位 QListQPoint emptyCells; for (int r 0; r 4; r) { for (int c 0; c 4; c) { if (m_grid[r][c] 0) { emptyCells.append(QPoint(r, c)); } } } if (emptyCells.isEmpty()) return; // 随机挑一个空位9/10概率放21/10放4 int index qrand() % emptyCells.size(); QPoint p emptyCells.at(index); m_grid[p.x()][p.y()] (qrand() % 10 0) ? 4 : 2; }qrand()是C标准库的随机函数需要在main里调用qsrand(QTime::currentTime().msec())初始化种子否则每次开局的随机序列都一样。这个坑隐蔽但很典型老版本的Qt程序几乎都栽过。游戏结束的判断逻辑是遍历整个棋盘只要还有空格或者左右相邻、上下相邻存在相等的数字就还能继续玩bool GameBoard::isGameOver() const { for (int r 0; r 4; r) { for (int c 0; c 4; c) { if (m_grid[r][c] 0) return false; if (c 1 4 m_grid[r][c] m_grid[r][c 1]) return false; if (r 1 4 m_grid[r][c] m_grid[r 1][c]) return false; } } return true; }这里判断的“相邻相等”是下一步还有没有合并机会的关键。注意这里的逻辑正确性依赖数字本身的值而不是格子有没有被合并过——2048的机制里两个相同数字永远可以再合并不需要记录合并历史。4. 界面渲染与交互QPainter绘制棋盘键盘事件驱动画面刷新4.1 paintEvent每次update()后重画整个棋盘窗口绘制全部集中在paintEvent里。这个函数由Qt在窗口初次显示、被遮挡后重新露出、或者代码手动调用update()时触发。你不能主动直接调用它只能通过update()请求系统调度void GameBoard::paintEvent(QPaintEvent *event) { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, true); const int margin 20; const int gap 10; const int topSpace 60; // 留给顶部分数 int boardSize width() - margin * 2; int cellSize (boardSize - gap * 3) / 4; // 画整个棋盘的背景底板 painter.setBrush(QColor(187, 173, 160)); painter.setPen(Qt::NoPen); painter.drawRoundedRect(margin, topSpace, boardSize, boardSize, 12, 12); for (int r 0; r 4; r) { for (int c 0; c 4; c) { int x margin gap c * (cellSize gap); int y topSpace gap r * (cellSize gap); // 每个格子独立的小圆角矩形 painter.setBrush(cellColor(m_grid[r][c])); painter.drawRoundedRect(x, y, cellSize, cellSize, 8, 8); // 非空格子写入数字 if (m_grid[r][c] ! 0) { painter.setPen(textColor(m_grid[r][c])); painter.setFont(valueFont(m_grid[r][c])); painter.drawText(QRect(x, y, cellSize, cellSize), Qt::AlignCenter, QString::number(m_grid[r][c])); } } } // 顶部显示当前分数 painter.setPen(Qt::black); QFont f font(); f.setPixelSize(20); painter.setFont(f); painter.drawText(QRect(margin, 10, width() - margin * 2, 40), Qt::AlignLeft | Qt::AlignVCenter, QString(Score: %1).arg(m_score)); QWidget::paintEvent(event); }核心逻辑是先画一个深色大底板再在大底板上循环画出16个格子。坐标计算是固定套路格子的x坐标等于margin gap c * (cellSize gap)中间留出间距看起来才有棋盘的样子。painter.setRenderHint(QPainter::Antialiasing, true)开启抗锯齿否则圆角矩形边缘会有明显的锯齿这是“看着像不像成品”的关键一行。4.2 颜色映射数字越大颜色越深2048的标志性视觉是不同数字对应不同颜色。源码里用一个switch做映射写法直接、容易改QColor GameBoard::cellColor(int value) const { 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); case 256: return QColor(237, 204, 97); case 512: return QColor(237, 200, 80); case 1024: return QColor(237, 197, 63); case 2048: return QColor(237, 194, 46); default: return QColor(60, 58, 50); } }深色底配浅色字浅色底配深色字这个对比关系决定了文字能不能看清。数值越大的格子颜色越深、越暖是2048视觉上给人“数值在升级”暗示的来源。后期想换皮肤只需要改这一个函数这也是把颜色逻辑单独抽出来的好处。像1024和2048在原版里其实还会有特殊动效这个简化版里没有留给二次开发去做。4.3 keyPressEvent方向键和R键重开按键响应是游戏交互的核心void GameBoard::keyPressEvent(QKeyEvent *event) { if (m_gameOver) { // 游戏结束时只有R键能重启 if (event-key() Qt::Key_R) restart(); return; } bool moved false; switch (event-key()) { case Qt::Key_Left: moved moveLeft(); break; case Qt::Key_Right: moved moveRight(); break; case Qt::Key_Up: moved moveUp(); break; case Qt::Key_Down: moved moveDown(); break; case Qt::Key_R: restart(); return; default: QWidget::keyPressEvent(event); return; } // 只有棋盘真的动过才生成新数字 if (moved) { spawnTile(); if (isGameOver()) { m_gameOver true; } update(); } }这段代码的纪律性很值得学先判断游戏状态再判断按键类型最后判断这次移动到底有没有造成变化。如果键盘按了但棋盘纹丝不动就不生成新数字——这是区分“有效移动”和“无效按键”的关键很多新手会把spawnTile()写在switch外面导致原地按方向键也会刷出新数字棋盘越来越满游戏难度凭空变高。另一个细节是update()只放在moved true的分支里。因为只有棋盘变化才需要重绘无效按键触发重绘纯粹浪费CPU。桌面应用这点开销无所谓但养成“数据变了才请求刷新”的习惯以后做动画、做实时渲染会少踩很多坑。5. 避坑排查从下载到跑通的常见问题全记录5.1 现象Qt Creator打开.pro文件报“Cannot find file”原因压缩包被解压到了中文路径、带空格的路径Qt4对路径编码的处理极其脆弱解析.pro时直接失败。解决把整个文件夹挪到纯英文路径比如D:\code\2048qt再重新打开工程文件。这个坑在中文Windows上出现概率非常高属于第一道门槛。5.2 现象编译报错找不到QWidget、QMainWindow原因用的是Qt5或更高版本打开老工程新版把窗口部件从QtGui拆到了QtWidgets。解决在.pro文件里把QT core gui改成QT core gui widgets然后右键工程执行“qmake”重新生成Makefile。这一步做完绝大多数Qt4工程都能在新版本上跑起来。5.3 现象改了头文件后编译报一堆“undefined reference to vtable”或者信号槽相关的错误原因Qt的元对象编译器(moc)没有重新生成对应文件。Qt工程里只要头文件包含Q_OBJECT就必须经过moc处理改过头文件后只重新编译.cpp是不够的。解决在Qt Creator里先“执行qmake”再重新构建。命令行环境下要保证moc生成的.moc文件也被包含进编译目标这个工作机制如果不了解会反复出现同样的报错。5.4 现象编译成功但生成的exe双击运行提示缺少QtCore4.dll或QtGui4.dll原因Qt4不是静态链接运行期需要动态库同行。解决把Qt安装目录bin下对应的dll复制到exe同目录或者把Qt的bin目录加到系统PATH。到了Qt5/Qt6时代有windeployqt工具可以自动打包Qt4没这么方便手动复制dll是常规操作。5.5 现象窗口显示正常但按方向键没反应原因GameBoard这个QWidget默认不会接收键盘焦点焦点停在父窗口或者其他控件上方向键事件根本没进到keyPressEvent。解决构造函数里调用setFocusPolicy(Qt::StrongFocus)如果还有别的窗口部件抢焦点就再补一个setFocus()。这是QWidget事件系统里最容易忽略的设定属于“看起来没问题但就是不动”的典型。5.6 现象连续按一次方向键[2,2,2,2]这一行直接变成8原因合并遍历写成了边扫描边合并合并完的数字在同一个循环里又参与了下一对比较。2048的规则是每行每次只能合并出一组新数字[2,2,2,2]应该变成[4,4,0,0]而不是[8,0,0,0]。解决严格走“压缩 → 合并 → 压缩”的三步流程合并循环里看清下标推进详见第3章写法。这6条是从实际跑这些Qt4小游戏高频遇到的问题里筛出来的几乎每一份老代码都会命中其中几条。我的习惯是拿到任何老工程先检查路径、再检查Qt版本、再检查setFocusPolicy按这个顺序排查能省下一晚上瞎定位的时间。6. 二次开发加上存档、撤销、动画三个实用改造方向6.1 用QSettings存最高分QSettings是Qt自带的轻量配置读写API三行就能把最高分落盘// 读取最高分 int bestScore settings.value(bestScore, 0).toInt(); // 分数变动时判断并写回 if (m_score bestScore) { settings.setValue(bestScore, m_score); }键名随意存的内容会自动写进注册表或配置文件里。适合这种小游戏不用自己解析JSON或者写文件。6.2 一个数组备份实现“撤销”撤销在2048里其实就是保存上一步的棋盘。多做一份备份数组每次按键前拷贝一份即可memcpy(m_backupGrid, m_grid, sizeof(m_grid)); // 然后执行移动 // 按键U时恢复备份 memcpy(m_grid, m_backupGrid, sizeof(m_grid));这个方案空间极小、逻辑极清晰比记录操作历史栈更适合作为初学前三个版本的迭代方向。6.3 用QTimer做滑移动画静态刷新改为分帧绘制是体验提升最明显的一项。思路是记录每个格子“从哪里来、到哪里去”用一个QTimer每15毫秒把当前偏移量推进一格然后调用update()重绘。这个改造会让整个手感质变建议先画六个方向调试通了再加。QTimer *timer new QTimer(this); timer-setInterval(15); connect(timer, SIGNAL(timeout()), this, SLOT(advanceAnimation()));注意Qt4的connect还是老式SIGNAL/SLOT写法搬到Qt5后换成函数指针写法即可。6.4 换肤把颜色映射改成配置驱动最简单的方式是定义一组QColor成员变量在restart()里从QSettings读取cellColor()不再写死RGB。想换主题的人只需要改配置值不需要动代码。我以前改这类小游戏总爱一步到位加好多功能后来发现一个方向一个方向验证更稳。从那以后我拿到源码第一件事永远是编译跑通跑通了再改第一处会动的代码改完立刻回归测试。这份2048qt工程适合从“改颜色、改窗口大小、改合并优先级”入手练习等把3.2节的合并逻辑看懂改熟整个Qt的事件机制和绘制流程也就盘活了。希望帮到你。本文还有配套的精品资源点击获取