ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

零基础用C++和Qt写2048小游戏:从控制台到图形界面实战

零基础用C++和Qt写2048小游戏:从控制台到图形界面实战

简介:这是一份面向C++与Qt4初学者的2048小游戏实战源码,适合刚接触GUI编程、想通过完整项目理解面向对象设计的学习者。项目以Qt4的QGraphicsView、QGraphicsItem构建游戏界面,用GameBoard维护棋盘状态与合并规则,Tile表示数字方块并处理数值更新与相邻合并,GameManager负责初始化、得分记录与结束判断,同时借助信号与槽机制完成滑动输入与逻辑响应。压缩包共6个文件,约7KB,包含2个cpp源文件、1个h头文件、1个ui界面文件、1个pro工程文件及1个user配置,覆盖从界面布局到核心逻辑的完整结构。目前已有270人学习。通过阅读与调试这份代码,读者可以掌握窗口创建、控件添加、自定义图形绘制、用户输入处理以及封装、继承、多态的实际运用,并锻炼排错与模块化组织能力,是进入游戏开发领域的一个轻量起点。

1. 零基础也能玩转:用 C++ 和 Qt 写一个 2048 小游戏到底有多难

你是不是也觉得,写游戏是那些敲了十年代码的大神才能干的事?一看到 C++、Qt 这些词,脑子里就自动弹出“复杂、难学、肯定搞不定”的弹幕?我第一次接触图形界面编程的时候也是这么想的,觉得能写出一个带窗口、能点、能动的程序,那得学到猴年马月去。但后来我发现,入门这件事,选对第一个项目比什么都重要。2048 这个游戏,规则简单到用一句话就能说清——上下左右滑动,相同的数字合并,凑出 2048 就赢。它没有复杂的物理引擎,没有网络通信,没有炫酷的 3D 渲染,就是一个 4x4 的格子,几个数字方块。而 Qt 呢,它把创建窗口、画方块、响应键盘这些脏活累活都封装好了,你只需要关心“数字怎么合并”这一件事。这份资源就是带你用 C++ 和 Qt 从零把这个小游戏跑起来,让你第一次感受到“我写的代码真的动起来了”的那种爽感。它适合完全没碰过图形界面、甚至 C++ 也只学了个皮毛的新手,只要你愿意装个环境、跟着敲几行代码,就能看到自己的窗口弹出来。

2. 为什么选 2048 当第一个项目:从黑乎乎的控制台到能看的窗口

2.1 控制台程序的局限与图形界面的吸引力

很多新手学 C++ 的第一个月,都是在黑乎乎的命令行里度过的。输出一句“Hello World”,算个 1+1,顶多写个猜数字的小游戏。这些东西能帮你熟悉语法,但说实话,挺枯燥的。你没法给别人展示,也没法获得那种“这是个真东西”的成就感。而图形界面程序不一样,它有一个实实在在的窗口,你能用鼠标点,能用键盘控制,能截图发给朋友看。这种正反馈对新手来说太重要了。2048 恰好是一个完美的过渡项目:它的逻辑不复杂,但视觉效果很直观。你每按一次方向键,屏幕上的方块就会移动、合并、变色,这种即时反馈是控制台程序给不了的。而且 Qt 这个框架,它把跨平台这件事也帮你搞定了,你写的代码在 Windows 上能跑,在 Linux 上也能跑,不需要你为每个系统重写一遍。

2.2 Qt 帮你省掉了哪些麻烦事

如果你不用 Qt,想纯手工创建一个窗口,那得跟操作系统的底层 API 打交道。Windows 有一套,Linux 有另一套,光是让窗口显示出来就得写几百行代码,新手直接劝退。Qt 把这些都封装成了类,你只需要继承一个QWidget,重写几个函数,窗口就出来了。画方块?用QPainter,几行代码就能画出带颜色的矩形。响应键盘?重写keyPressEvent函数,判断一下按的是哪个键就行。定时器?QTimer类帮你搞定。信号和槽机制更是 Qt 的招牌,对象之间通信变得特别自然。对于 2048 这个项目来说,你真正需要自己写的核心逻辑,其实就是“怎么把二维数组里的数字按照规则移动和合并”。剩下的窗口、绘图、事件处理,Qt 都给你准备好了模板。这就是为什么我说,选对框架,入门难度直接减半。

2.3 从 2048 里能学到什么真本事

别看 2048 小,它涵盖了一个图形界面程序该有的基本要素。第一,数据结构:你需要一个 4x4 的二维数组来存每个格子的数字。第二,算法逻辑:上下左右四个方向的移动,本质上是数组元素的遍历和合并,这里面有循环、条件判断、状态标记,是练习逻辑思维的好机会。第三,事件驱动:程序不是从头跑到尾就结束了,而是等待用户按键,然后做出反应,这是 GUI 编程的核心思维。第四,绘图:怎么把数组里的数字映射成屏幕上的方块和文字,涉及到坐标计算和颜色管理。第五,状态管理:什么时候游戏结束,什么时候赢了,怎么重新开始。这些东西,你写一遍 2048 就全练到了。以后你再学其他的 GUI 框架,或者做更复杂的项目,这些经验都是通用的。我当初写完 2048 之后,再看那些复杂的界面程序,心里就有底了,知道它们大概是怎么组织起来的。

3. 动手前的准备:把环境和第一个窗口跑起来

3.1 安装 Qt 和配置编译环境

入门的话,建议直接装 Qt 官方的集成开发环境 Qt Creator。它自带编译器、调试器和界面设计器,省得你单独去配 MinGW 或者 MSVC。下载的时候选开源版本就行,安装过程中会让你勾选组件,记得把 Qt 的某个版本(比如 Qt 5.15 或者 Qt 6.x)和对应的编译器都勾上。如果你是在 Windows 上,安装包大概一两个 G,耐心等一会儿。装完之后打开 Qt Creator,界面看起来可能有点复杂,但别慌,你只需要用到几个地方:左边的“项目”列表,中间的代码编辑区,下面的“编译输出”窗口,还有左下角的绿色三角运行按钮。第一次打开可能会让你选 Kit,就是编译工具套件,默认的桌面 Kit 直接点下一步就行。配置好之后,新建一个项目,选“Qt Widgets Application”,给它起个名字,比如“My2048”,然后一路下一步,Qt Creator 会自动帮你生成一个带窗口的空项目。这时候你点一下绿色三角,应该就能弹出一个空白窗口了。恭喜你,环境搞定了。

3.2 看懂 Qt 项目的文件结构

新建完项目,左边会列出几个文件。.pro文件是项目配置文件,里面写着用了哪些 Qt 模块,比如QT += widgets就是告诉编译器要用到窗口部件模块。main.cpp是程序入口,里面就几行代码:创建一个QApplication对象,然后实例化你的主窗口类,显示出来,最后进入事件循环。mainwindow.h和mainwindow.cpp是你主窗口类的头文件和实现文件,你后面大部分代码都会写在这里。还有一个.ui文件,是界面设计文件,不过 2048 这个项目我们直接手写代码来画,不用拖控件,所以可以暂时忽略它。理解这几个文件的关系很重要:.pro管配置,main.cpp管启动,mainwindow管窗口逻辑。你不需要一下子全弄懂,先知道改哪里能让窗口变样就行。

3.3 第一个能跑的例子:在窗口上画一个方块

现在我们来写第一段真正干活的代码。打开mainwindow.h,在类里面声明一个paintEvent函数。这个函数是 Qt 提供的回调,窗口需要重绘的时候会自动调用它。然后在mainwindow.cpp里实现它。

// mainwindow.h 中在类声明里加上 protected: void paintEvent(QPaintEvent *event) override;
// mainwindow.cpp 中实现 #include <QPainter> void MainWindow::paintEvent(QPaintEvent *event) { Q_UNUSED(event); // 暂时不用这个参数,避免编译器警告 QPainter painter(this); // 创建画笔,绑定到当前窗口 painter.setBrush(Qt::blue); // 设置填充颜色为蓝色 painter.drawRect(50, 50, 100, 100); // 在坐标(50,50)处画一个100x100的矩形 }

这段代码的逻辑很直白:QPainter是 Qt 的绘图工具,你给它一个“画布”(这里就是当前窗口),它就能在上面画东西。setBrush设置填充色,drawRect画矩形,四个参数分别是左上角的 x 坐标、y 坐标、宽度、高度。坐标原点在窗口的左上角,x 向右增加,y 向下增加。运行一下,你应该能看到窗口左上角出现一个蓝色方块。别小看这个方块,它就是你后面画 2048 数字块的基础。你可以试着改改坐标和大小,看看方块位置怎么变。我第一次跑出这个方块的时候,盯着看了好一会儿,觉得特别神奇——原来画东西这么简单。

4. 核心逻辑拆解:让数字方块动起来

4.1 用二维数组表示游戏棋盘

2048 的棋盘是一个 4x4 的格子,每个格子里要么是 0(表示空),要么是 2、4、8 这样的数字。在 C++ 里,最自然的表示方法就是一个二维数组。我们可以在主窗口类里加一个成员变量:

int board[4][4]; // 4x4的棋盘,0表示空格

然后在构造函数里把它全部初始化为 0,再随机在两个位置生成初始数字 2 或 4。生成随机数可以用 Qt 的QRandomGenerator,也可以用 C++ 标准库的rand()。这里用 Qt 的方式:

#include <QRandomGenerator> // 在构造函数中初始化棋盘 for (int i = 0; i < 4; ++i) for (int j = 0; j < 4; ++j) board[i][j] = 0; // 随机生成两个初始数字 for (int k = 0; k < 2; ++k) { int row = QRandomGenerator::global()->bounded(4); // 0到3的随机数 int col = QRandomGenerator::global()->bounded(4); board[row][col] = (QRandomGenerator::global()->bounded(2) + 1) * 2; // 2或4 }

bounded(4)返回 0 到 3 的随机整数,正好对应数组下标。bounded(2)返回 0 或 1,加 1 再乘 2,就得到 2 或 4。这段代码跑完之后,棋盘上就有两个随机位置的数字了。但是窗口上还看不到,因为我们还没在paintEvent里根据这个数组来画方块。接下来就要把数组和绘图连起来。

4.2 把数组画成看得见的方块

在paintEvent里,我们不再画一个固定的蓝方块,而是遍历整个board数组,根据每个格子的值来决定画什么。先定义一些常量:每个方块的大小、间距、起始偏移量。

void MainWindow::paintEvent(QPaintEvent *event) { Q_UNUSED(event); QPainter painter(this); const int tileSize = 80; // 每个方块80x80像素 const int gap = 10; // 方块之间的间距 const int startX = 20; // 棋盘左上角x坐标 const int startY = 20; // 棋盘左上角y坐标 for (int i = 0; i < 4; ++i) { for (int j = 0; j < 4; ++j) { int x = startX + j * (tileSize + gap); int y = startY + i * (tileSize + gap); // 先画背景方块 painter.setBrush(Qt::lightGray); painter.drawRect(x, y, tileSize, tileSize); // 如果格子里有数字,画上文字 if (board[i][j] != 0) { painter.setPen(Qt::black); QFont font = painter.font(); font.setPixelSize(24); painter.setFont(font); painter.drawText(QRect(x, y, tileSize, tileSize), Qt::AlignCenter, QString::number(board[i][j])); } } } }

这段代码里,i是行,j是列。x和y算出来的是每个方块在窗口里的像素位置。先画一个浅灰色的底,如果数组里那个位置不是 0,就用drawText把数字写在方块正中间。Qt::AlignCenter让文字居中。QString::number把整数转成字符串。运行一下,你应该能看到一个 4x4 的灰色格子阵,其中两个格子里有数字 2 或 4。这就是你的 2048 棋盘雏形了。你可以试着改改tileSize和gap,看看布局怎么变化。我当初调这几个参数调了好几次,才找到一个看起来舒服的比例。

4.3 键盘事件:让方块响应方向键

棋盘画出来了,但按键盘没反应。我们需要重写keyPressEvent函数。在mainwindow.h里声明它,然后在mainwindow.cpp里实现。

// mainwindow.h 中声明 protected: void keyPressEvent(QKeyEvent *event) override;
// mainwindow.cpp 中实现 #include <QKeyEvent> void MainWindow::keyPressEvent(QKeyEvent *event) { switch (event->key()) { case Qt::Key_Up: // 这里调用向上移动的逻辑 break; case Qt::Key_Down: // 向下移动 break; case Qt::Key_Left: // 向左移动 break; case Qt::Key_Right: // 向右移动 break; default: QMainWindow::keyPressEvent(event); // 其他键交给父类处理 } update(); // 请求重绘窗口 }

event->key()返回按下的键的枚举值。Qt::Key_Up这些就是方向键。每个 case 里暂时留空,后面填上移动逻辑。最后调用update(),它会触发paintEvent,让窗口重新画一遍。现在你按方向键,虽然棋盘不会变,但程序已经能捕获到按键了。你可以先在每个 case 里加一句qDebug() << "Up pressed";来验证按键是否被识别。qDebug()是 Qt 的调试输出,会在编译输出窗口里打印信息。确认按键没问题之后,就可以开始写最核心的移动合并算法了。

5. 避坑指南:新手最容易翻车的几个地方

5.1 数组越界导致程序崩溃

现象:程序运行到某一步突然闪退,或者弹出“段错误”之类的提示。原因:在遍历数组或者计算新位置的时候,下标超出了 0 到 3 的范围。比如你想检查board[i+1][j],但i已经是 3 了,i+1就是 4,访问board[4][j]就越界了。解决:在访问相邻格子之前,一定要先判断下标是否合法。比如if (i + 1 < 4)再访问board[i+1][j]。写循环的时候,条件用i < 4而不是i <= 4。这个坑我踩过好几次,尤其是写合并逻辑的时候,一不留神就忘了边界检查。

5.2 忘记调用 update() 导致画面不刷新

现象:按了方向键,数组里的数字确实变了,但窗口上显示的还是老样子。原因:Qt 的绘图不是自动的,你修改了数据之后,必须告诉窗口“你需要重画”。update()函数就是干这个的,它会安排一次重绘事件。如果你忘了调用它,paintEvent就不会被触发,画面自然不变。解决:在每次修改board数组之后,都加上update()。通常放在keyPressEvent的末尾,或者移动逻辑函数的最后。养成习惯:改数据,就update()。

5.3 信号槽连接失败导致按钮没反应

现象:你加了一个“重新开始”按钮,但点击之后什么也没发生。原因:Qt 的信号槽机制需要你把按钮的clicked信号连接到某个槽函数上。如果连接的时候写错了对象名或者函数名,或者忘了写connect,按钮就不会触发任何逻辑。解决:检查connect语句的四个参数:发送者、信号、接收者、槽。比如connect(restartButton, &QPushButton::clicked, this, &MainWindow::restartGame);。确保restartGame函数在头文件里声明了,并且是public slots或者private slots下的。如果编译报错说找不到信号或槽,多半是拼写错了或者忘了加Q_OBJECT宏。

5.4 随机数每次运行都一样

现象:每次启动游戏,初始数字的位置和值都一模一样,毫无随机性。原因:如果你用的是 C 语言的rand()而没有调用srand()设置种子,或者种子设成了固定值,那么每次程序启动的随机序列都是相同的。解决:用 Qt 的QRandomGenerator::global()就不需要手动设种子,它内部已经处理好了。如果非要用rand(),记得在程序启动时调用srand(time(nullptr)),用当前时间做种子。不过既然用了 Qt,直接用QRandomGenerator更省心。

5.5 中文乱码或者文字显示成方块

现象:你想在窗口上显示中文提示,结果出来的是一堆问号或者方块。原因:源代码文件的编码和 Qt 运行时的编码不一致。Qt 默认用 UTF-8 处理字符串,但如果你的源文件保存成了 GBK 编码,中文字符就会解析错误。解决:在 Qt Creator 里,把源文件的编码统一设成 UTF-8。菜单栏“编辑”->“Select Encoding”,选 UTF-8。另外,在main.cpp里可以加上QTextCodec::setCodecForLocale(QTextCodec::codecForName("UTF-8"));,不过 Qt 5 之后一般不需要了。最稳妥的办法是尽量用英文写代码,中文只出现在字符串里,并且确保文件是 UTF-8。

6. 下一步怎么走:从能跑到好用,再到自己发挥

6.1 把移动合并逻辑补完整

现在你的程序已经能显示棋盘、响应按键了,但按方向键之后数字还不会动。你需要实现四个方向的移动逻辑。以向左移动为例,思路是这样的:对于每一行,先把非零数字挤到左边,然后相邻相同的合并,合并之后再把后面的零补上。这个过程可以用一个临时数组来辅助。写完之后,向右、向上、向下都是类似的,只是遍历方向不同。你可以先在一个方向上写通,测试没问题了,再复制修改到其他方向。我当初写的时候,向上和向下搞混了好几次,后来画了个图才理清楚。建议你也拿张纸画一画,比在脑子里空想强。

6.2 加上游戏结束和胜利的判断

每次移动之后,都要检查两件事:有没有出现 2048?如果出现了,弹出提示“你赢了”,然后可以选择继续或者重开。棋盘是不是满了而且没有相邻相同的数字?如果是,弹出“游戏结束”。判断棋盘满没满很简单,遍历一遍看有没有 0。判断有没有相邻相同,就是检查每个格子和它右边、下边的格子是否相等。这两个判断写成一个函数,每次移动后调用一次。注意,胜利判断只需要在出现 2048 的那一次触发,不要每次移动都弹窗,不然会很烦。可以用一个布尔变量记录是否已经提示过胜利。

6.3 美化界面和增加重开功能

基础功能跑通之后,你可以开始折腾外观了。给不同的数字配不同的背景色,2 是浅灰,4 是米黄,8 是橙色,越大颜色越深。文字颜色也可以根据背景调整,深色背景用白字,浅色背景用黑字。加一个“重新开始”按钮,点击之后把棋盘清零,重新生成两个初始数字,然后update()。按钮可以放在窗口底部,用QPushButton创建,然后用connect连接到你的重置函数。如果你想让界面更整齐,可以用QGridLayout来布局,不过对于 2048 这种固定位置的游戏,直接算坐标画也挺好。我后来还加了个分数显示,每次合并的时候把合并的数字累加到总分里,显示在窗口上方。这些扩展都不难,你可以根据自己的想法慢慢加。

6.4 学习路径建议

如果你跟着这个项目走完了,接下来可以往几个方向深入。第一,把 C++ 的基础再打牢一些,特别是类和对象、指针、引用、STL 容器这些,它们在 Qt 里到处都是。第二,系统学一下 Qt 的模型/视图架构,理解QAbstractItemModel和QListView的配合,这对做列表类界面很有用。第三,试试用 Qt 的界面设计器拖控件,而不是纯手写代码,感受一下可视化布局的便利。第四,如果你对游戏开发感兴趣,可以了解一下QGraphicsView框架,它能管理大量图形项,适合做更复杂的 2D 游戏。第五,把 2048 的代码重构一下,把游戏逻辑和界面显示分开,写成独立的类,这样代码更清晰,也方便以后移植到其他平台。我当初写完第一版之后,隔了一个月又重写了一遍,发现理解深了很多。从那以后我每次学新框架,都强制自己先写一个最小可运行的小项目,跑通了再去看文档细节。希望这个 2048 能成为你图形界面编程的起点,帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进