
简介一款基于Qt4开发的2048小游戏C源码面向刚开始学习C和GUI编程的初学者以经典数字拼图玩法为载体覆盖了从界面搭建到游戏逻辑实现的完整流程非常适合作为实践项目或课程设计参考。压缩包共6个文件包含2个cpp源文件、1个头文件、1个ui界面文件和1个pro工程文件整体仅7KB结构精简且便于逐文件阅读。目前已有266人参与学习下载。项目内GameBoard类负责棋盘状态与合并规则Tile类继承QGraphicsItem实现方块显示与数值更新GameManager类统筹游戏流程并处理滑动事件三者配合可帮助理解封装、继承、多态以及Qt信号槽通信机制同时掌握QGraphicsView绘图、窗口控件布置和用户输入响应等关键技能。通过对照源码实际调试读者还能提升代码阅读和排错能力为进一步学习大型Qt应用开发打下基础。1. 2048qt小游戏C初学不是界面难是合并逻辑绕很多人下载“2048qt小游戏C初学”这类zip包以为难点在窗口绘制、动画这些看得见的部分实际上真正卡住初学者的永远是那几行合并算法。一台4x4棋盘、一套方向键、几个数字方块看起来简单但四个方向的移动要让同一份逻辑复用不处理好就会变成“按上和按右结果相反”的翻车现场。这篇笔记围绕这个Qt小游戏项目展开先讲工程骨架再把合并算法拆开揉碎最后落到绘制、键盘事件和几个高频踩坑点适合刚学完C语法、想拿一个桌面项目练手的人。目标就一个让你照着思路自己写出一份能跑、能玩、能扩展的2048。2. 拆开zip一个最小Qt 2048工程该有哪些文件2.1 工程结构逻辑、界面、入口三类文件分工下载解压常见的2048qt小游戏C初学项目一般不会是一个杂乱的大文件而是按职责分成三块。第一块是入口文件比如main.cpp负责启动QApplication、创建主窗口、进入事件循环第二块是界面层比如MainWindow和BoardWidget负责接收按键、调用QPainter绘制棋盘、更新分数第三块是逻辑层也就是GameBoard或者Board类只负责维护4x4棋盘数据、实现合并和生成新块不关心任何可视化内容。这个分离是初学阶段最容易忽视却最值得学的习惯界面和逻辑混在一起的项目后续加撤销、存盘功能时会非常痛苦。Qt工程里还有一个不可少的文件就是后缀为.pro的工程描述文件。很多初学者拿到zip第一反应是直接用编译器打开.cpp结果报一堆找不到头文件的错就是因为没有先认识.pro文件的作用。它告诉qmake三件事这个项目依赖哪些Qt模块、编译出什么名字的可执行文件、需要收集哪些源码和头文件。下面是一份精简配置能支撑起一个完整的2048游戏。QT core gui greaterThan(QT_MAJOR_VERSION, 4): QT widgets TARGET 2048 TEMPLATE app SOURCES main.cpp \ mainwindow.cpp \ gameboard.cpp HEADERS mainwindow.h \ gameboard.h这里的QT core gui是Qt5之后的模块声明方式如果用Qt5还要在大于4的版本判断里加上widgets因为QApplication、QPainter、QWidget都集中在widgets模块里。TARGET决定最终生成的可执行文件名比如在Windows下就是2048.exeTEMPLATE app表示这是一个应用程序而不是库。SOURCES和HEADERS不需要写绝对路径只要这些文件和.pro文件在同一个目录下或者写相对路径即可。初学者最常见的错误是新建源码文件后忘了加进SOURCES导致编译通过但运行时报“未定义的引用”。2.2 用qmake在本地跑出第一个窗口最小命令与参数拿到一个完整的2048qt小游戏C初学zip目录后我一般不会先去读代码而是先编译一次确认环境能跑通。Qt 5和Qt 6项目大多兼容qmake这套构建流程在Windows上如果装的是MinGW版Qt打开Qt的命令行环境进入zip解压目录后依次执行三条命令就够了。qmake 2048.pro mingw32-make ./2048.exeqmake这一步会根据.pro文件生成Makefile它是整个构建的“中间人”。mingw32-make是MinGW环境下的构建器对应MSVC环境则换成nmake。如果你的Qt安装目录里既有MinGW也有MSVC版本务必让命令行里的qmake和编译器匹配否则会出现“编译器与Qt库不兼容”的链接错误这类问题与代码本身无关属于典型的环境坑。跑出第一个窗口后再回头读main.cpp就轻松多了。#include QApplication #include mainwindow.h int main(int argc, char *argv[]) { QApplication app(argc, argv); MainWindow w; w.show(); return app.exec(); }这段代码是几乎所有Qt窗口程序的固定开场。QApplication负责管理GUI程序的控制流和主要设置比如全局样式、字体、剪贴板app.exec()启动事件循环程序在这里不断接收键盘、鼠标、重绘等事件直到窗口关闭才返回。很多初学者会疑惑为什么没有看到while循环程序却能一直运行答案就在exec()里它内部就是一个事件分发循环窗口的按键响应、定时器触发都依赖它正常运行。2.3 从数据结构到类划分游戏逻辑先独立出来我见过不少初学项目把棋盘数据放在MainWindow的成员变量里按钮、分数标签、棋盘绘制全部挤在一个类中。这种做法在几百行内能跑通但一旦加上撤销、动画、存档MainWindow会膨胀到无法维护。更推荐的划分是把棋盘封装成独立的GameBoard类内部用二维数组保存格子值对外只暴露移动、判断结束、获取棋盘状态这几个接口。class GameBoard { public: GameBoard(); void moveLeft(); void moveRight(); void moveUp(); void moveDown(); int tileAt(int row, int col) const; int score() const; bool isGameOver() const; void reset(); private: int board[4][4]; int totalScore; bool moveAndMergeLine(int line[4]); };这样设计的好处很直接MainWindow只需要根据按键调用对应方向方法再调用update()重绘而游戏规则全部收在GameBoard内部便于单独测试。二维数组用board[4][4]值0表示空格2、4、8等表示方块数值。初学阶段用固定数组就够了等你想做更大棋盘时再改成std::vector。注意这里的moveAndMergeLine返回bool用来表示这一行是否有任何变化这个返回值是后续判断“游戏是否还能继续”的关键线索。3. 2048的核心四方向合并算法与随机生成逻辑3.1 先做对“一行合并”再谈四个方向2048的规则本质上可以压缩成一句话每次移动时所有方块朝指定方向滑动遇到相同的数字就合并一次并且合并后的方块不参与本轮再次合并。比如一行是[2, 2, 2, 2]向左滑动后应该得到[4, 4, 0, 0]而不是[8, 0, 0, 0]一行是[2, 2, 4, 0]向左合并的结果是[4, 4, 0, 0]。初学者最容易在这里犯错要么连续合并要么漏掉中间的0。我一般会先实现一个只处理单行向左合并的函数把问题缩小到“一行四个数”的规模。先剥离所有0然后从左到右扫描相邻相等就合并一次并跳过被合并的数最后把剩余数字写回行首。bool GameBoard::mergeLine(int line[4]) { int temp[4] {0, 0, 0, 0}; int pos 0; bool changed false; for (int i 0; i 4; i) { if (line[i] ! 0) temp[pos] line[i]; } for (int i 0; i 3; i) { if (temp[i] ! 0 temp[i] temp[i 1]) { temp[i] * 2; totalScore temp[i]; temp[i 1] 0; changed true; i; } } pos 0; for (int i 0; i 4; i) { if (temp[i] ! 0) line[pos] temp[i]; else line[i] 0; } for (int i 0; i 4; i) { if (line[i] ! temp[i]) changed true; } return changed; }注意两个细节第一个for循环把非0元素压缩到temp数组前部相邻相同数字才有机会挨在一起第二个for循环里合并后执行了i这是为了防止[4, 4, 4, 4]这种行被一轮内合并成[8, 8, 0, 0]而不是[16, 0, 0, 0]。最后的changed判断方式是逐个位置比较line[i]与temp[i]的最终值只要有任一位置不同就说明这行发生了变化这个返回值用来决定是否生成新块。3.2 方向数组与转置把四个方向统一成一种逻辑单行的向左合并做好后整个棋盘向左移动就是逐行调用mergeLine。但向右呢常见的做法是把每一行反转调用mergeLine再反转回来向上呢先把整个棋盘转置就变成了向左移动处理完再转置回来向下则是“转置 向右移动 再转置”。这套组合让代码量非常精简而且不需要为每个方向复制粘贴一份逻辑。void GameBoard::moveLeft() { for (int row 0; row 4; row) { if (mergeLine(board[row])) moved true; } } void GameBoard::moveRight() { for (int row 0; row 4; row) { reverseLine(board[row]); if (mergeLine(board[row])) moved true; reverseLine(board[row]); } } void GameBoard::transpose() { for (int row 0; row 4; row) { for (int col row 1; col 4; col) { int tmp board[row][col]; board[row][col] board[col][row]; board[col][row] tmp; } } } void GameBoard::moveUp() { transpose(); moveLeft(); transpose(); } void GameBoard::moveDown() { transpose(); moveRight(); transpose(); }代码里moved是成员变量移动前先置为false只要任一行发生变化就变成true。moveUp用转置加moveLeft实现moveDown用转置加moveRight实现这是最不容易出错的方向映射。reverseLine可以自己写一个简单循环交换或者直接用std::reverse。这套方法的巧妙之处在于游戏逻辑的核心只需要维护“单行向左合并”其他方向全靠预处理变换可读性远高于用dx、dy方向数组硬推的写法。3.3 随机生成、移动判定和结束判定新人最容易偷懒的地方每次玩家操作后棋盘需要在一个随机空位生成新方块。常见规则是90%概率生成210%概率生成4这个比例和原版游戏一致。如果只是随机挑空位不考虑“这次操作到底有没有移动”会出现一个经典问题棋盘被堵死时按方向键明明没有任何变化却仍然刷出新方块。我一般会先记录移动前的棋盘状态操作后对比发生了变化才生成新块。void GameBoard::spawnTile() { std::vectorstd::pairint, int emptyCells; for (int row 0; row 4; row) { for (int col 0; col 4; col) { if (board[row][col] 0) emptyCells.push_back({row, col}); } } if (emptyCells.empty()) return; int index std::rand() % emptyCells.size(); int row emptyCells[index].first; int col emptyCells[index].second; board[row][col] (std::rand() % 10 9) ? 2 : 4; }注意使用std::rand前要调用std::srand设置随机种子一个常见的做法是在main.cpp开头用std::srand(static_cast (time(nullptr)))初始化。emptyCells用vector收集所有空位再随机取一个下标这个写法比不断重试随机坐标要可靠且高效。游戏结束的判定也基于一个简单的遍历只要存在空位或者任意相邻两个格子值相同游戏就还有救。bool GameBoard::isGameOver() const { for (int row 0; row 4; row) { for (int col 0; col 4; col) { if (board[row][col] 0) return false; if (col 1 4 board[row][col] board[row][col 1]) return false; if (row 1 4 board[row][col] board[row 1][col]) return false; } } return true; }这段判定的思路是“逆否命题”如果没有空位且不存在相邻相同值那确实无路可走。这里只检查右方和下方因为左方和上方的相邻关系已经被覆盖到了没必要重复判断。4. 用QPainter把棋盘画出来绘制、重绘与键盘事件4.1 坐标换算格子尺寸与像素的对应关系逻辑层处理的是0到3的行列下标界面层需要把行列映射到窗口像素坐标。这里最容易犯的错误是写死格子尺寸比如设定格子宽120像素然后窗口一拉伸棋盘还是那块区域周围留下一大片空白。正确做法是每次绘制时根据当前窗口尺寸计算格子大小我用width()和height()取窗口客户区的宽高除以棋盘边长4得到每个格子的像素宽度。void BoardWidget::paintEvent(QPaintEvent*) { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, true); int side qMin(width(), height()); int cell side / 4; for (int row 0; row 4; row) { for (int col 0; col 4; col) { QRectF rect(col * cell 4, row * cell 4, cell - 8, cell - 8); painter.setBrush(colorForValue(board-tileAt(row, col))); painter.setPen(Qt::NoPen); painter.drawRoundedRect(rect, 10, 10); int value board-tileAt(row, col); if (value ! 0) { painter.setPen(textColorForValue(value)); QFont font painter.font(); font.setPixelSize(cell / 3); font.setBold(true); painter.setFont(font); painter.drawText(rect, Qt::AlignCenter, QString::number(value)); } } } }这里的关键参数是每格四周留4像素间距所以矩形从col * cell 4开始宽高为cell - 8这样格子之间有8像素的缝隙视觉效果不会挤在一起。setRenderHint开启抗锯齿文字和圆角边缘更平滑。drawRoundedRect的圆角半径用了10拉大窗口时这个值不再变化如果想更精细可以写成cell / 10。字体大小用cell / 3会随窗口放大缩小避免大窗口里文字看起来偏小。4.2 按键事件方向键映射与刷新时机QWidget要响应键盘前提是窗口本身获得了输入焦点。很多初学者发现方向键没反应就是因为主窗口没有设置焦点策略。在MainWindow构造函数里调用setFocusPolicy(Qt::StrongFocus)是最直接的做法。按键事件的触发位置是keyPressEvent需要包含QKeyEvent头文件。void MainWindow::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; } if (moved) { board-spawnTile(); boardWidget-update(); scoreLabel-setText(QString(分数: %1).arg(board-score())); if (board-isGameOver()) QMessageBox::information(this, 结束, 棋盘已满游戏结束); } }这里move相关方法改成了返回bool因为只有真正发生了移动才应该生成新块、更新界面。boardWidget-update()是关键的一行它不会立刻重绘而是向Qt事件系统发送一个重绘请求等当前事件处理完再触发paintEvent。QPainter的绘制不允许跳过update()直接用自定义draw函数事件循环里的重绘机制是Qt约定俗成的做法。分数标签用QString::arg取代字符串加法格式更清晰也不会因为数字转QString产生歧义。4.3 信号槽的另一种用法分数刷新与游戏结束通知界面更新不一定要全部写在keyPressEvent里。更符合Qt习惯的做法是让GameBoard在分数变化后发出信号MainWindow用connect接收并更新标签。不过对于2048这个规模的项目直接在按键事件里更新分数标签更直观代码路径短心智负担小特别适合初学阶段。如果你以后要扩展每步移动后发出tileMoved或scoreChanged信号让MainWindow的槽函数负责刷新界面。这种写法把“数据变化”和“界面更新”解耦新增功能时不用改GameBoard。但一定要理解信号槽连接是编译期或运行期建立的一种回调关系connect函数第一个参数是发送者对象第二个参数是信号第三个是接收者对象第四个是槽函数。初学者最容易忘记的是线程和对象生命周期问题只要发送者或接收者被销毁连接就失效所以MainWindow一般作为接收者它的生命周期覆盖整个游戏过程。5. 初学者避坑2048 Qt项目最常见的4个翻车点5.1 按上和按下结果一样转置忘记变方向先用最简单的描述记录。现象是写好了moveUp和moveDown按两下感觉没区别或者移动方向完全相反。原因是不少人把moveDown写成“转置 moveLeft 再转置”但moveUp也这么写的只是行向量方向没变。解决方法是moveDown必须基于moveRight实现先将棋盘转置让列变成行对每行执行“反转 合并 反转”再转置回来。你可以用一行[2, 0, 2, 0]手动推导一遍向上合并时第一列从上到下读相当于原始棋盘的第一列从左向右合并而向下合并时要先反转结果才不会互相颠倒。5.2 按键没反应焦点被子控件抢走现象是棋盘能显示鼠标能点但按键盘完全无响应。原因多数是MainWindow里某个子控件抢占了焦点或者主窗口没调用setFocusPolicy。解决方法是建立固定检查顺序先看构造函数里有没有setFocusPolicy(Qt::StrongFocus)再看有没有其他ChildWidget把焦点抢走必要时在keyPressEvent里加qDebug输出确认事件到底有没有进来。用qDebug定位问题比盲改代码高效得多因为它是“事件回顾”而不是“猜原因”能直接告诉你程序是否走到了再分发的分支。5.3 窗口一拉大格子变形绘制坐标写死现象是固定窗口尺寸时一切正常拉伸窗口时棋盘完全不跟着变化或者格子被拉成矩形。原因是格子尺寸在构造函数里算了一次存成固定变量paintEvent直接引用。底层绘制的逻辑脱离当前窗口状态Qt只在该重绘的时候调用你赋给paintEvent的代码如果代码里读的是过时的尺寸重新绘制也还是旧布局。解决方法是把格子尺寸改成在paintEvent内部实时计算用width()、height()、min三者配合保证任何窗口尺寸下都居中、比例正确。5.4 中文乱码源码编码和Qt版本不一致现象是在Windows上用MSVC编译器编译界面上的中文显示成乱码但英文正常。原因分两种老版本Qt对源码文件默认按本地编码解析而MSVC默认按GBK读取源码如果你的.cpp文件保存成UTF-8就会解析错另一种是字符串里直接写了中文字面量转换时没有走正确的编码。解决方法是把源码文件统一保存为UTF-8在.pro文件里设置mingw或MSVC的编码选项或者在所有中文字符串上使用QString::fromUtf8(分数)。如果用Qt Creator在工具里把默认文件编码改成UTF-8就能避免大部分问题。6. 进阶给2048加一个“后悔药”撤销功能撤销功能是2048项目扩展方向里性价比最高的一项它不涉及复杂数学只需要在移动前保存一份棋盘快照。核心思路是用std::vector保存历史状态每步操作前把当前棋盘push进去撤销时从栈顶弹出一份恢复。听起来像把数据复制一份那么简单实际上有几个细节需要处理一是分数也要跟着回滚二是保存历史前要把这次移动的随机生成结果排除在外三是历史数据的容量要有限制。void GameBoard::pushSnapshot() { if (history.size() 20) history.erase(history.begin()); history.push_back(*this); } void GameBoard::undo() { if (history.empty()) return; *this history.back(); history.pop_back(); }这里的*this保存了整个GameBoard对象包括棋盘数组、分数、历史记录本身所以恢复后不需要额外重置其他成员。限制历史为20步是为了防止内存和撤销无限增长恢复时也保留了最近的操作链。GameBoard要能被这样拷贝内部不要持有QObject派生对象或裸指针这也是当初把界面与逻辑分离的回报之一。移动方法的调用顺序也要调整先pushSnapshot再执行移动合并最后spawnTile。因为生成的随机方块只属于“移动后的结果”不应该被记入历史。如果在pushSnapshot之前就生成了方块撤销时回到的就不完全是上一步手操作前的初始状态几次撤销后局面会明显不对。键盘上建议同时支持CtrlZ和Backspace两个快捷键这样既照顾了习惯快捷键的用户也照顾了更倾向单击动作的玩家。做完撤销后你会自然意识到棋盘状态无处不在——按键要读它、绘制要读它、游戏结束要读它一但逻辑层把状态收拢在一个类里任何扩展都水到渠成。我第一次把这个功能补进去时犯过很傻的错历史记录也存了GameBoard对象结果撤销时把历史也弹掉只按一下就连退好几步。后来改成只保存棋盘和分数两个轻量字段逻辑马上变清晰。这种经验只有真正动手才拿得到希望这篇笔记能帮你避开那些我踩过的坑早日写出属于你自己的2048qt小游戏C初学项目。本文还有配套的精品资源点击获取