ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Qt实战:用C++写2048小游戏,打通信号槽与事件循环

Qt实战:用C++写2048小游戏,打通信号槽与事件循环

简介:这是一份面向C++与Qt4初学者的2048小游戏实战源码,适合刚接触GUI编程、希望通过完整项目理解面向对象设计的学习者。项目以Qt4跨平台框架为基础,借助QGraphicsView与QGraphicsItem构建游戏界面,实现方块绘制与动态效果,并围绕GameBoard、Tile、GameManager等类组织棋盘状态、合并规则、得分与结束判定,同时用信号槽机制串联用户输入与游戏逻辑。压缩包共6个文件,包含2个cpp源文件、1个h头文件、1个ui界面文件、1个pro工程文件及1个user配置,整体约7KB,结构精简,便于直接编译运行与逐行阅读。目前已有266人学习下载。通过这份代码,读者可以掌握窗口创建、控件添加、自定义图形绘制与事件处理,并体会封装、继承、多态在游戏逻辑中的落地方式,是入门游戏开发与巩固C++基础的实用起点。

1. 从一份 2048qt小游戏C++初学.zip 说起:为什么它比你想的更值得拆

很多人第一次看到“2048qt小游戏C++初学.zip”这个标题,第一反应是:又一个练手小项目,能学到什么?但如果你真的动手把 2048 用 Qt 写一遍,会发现它几乎把 C++ 初学者到初级工程师之间那段最尴尬的空白全填上了——信号槽怎么连、QWidget 怎么画、键盘事件怎么接、二维数组怎么和界面同步、分数怎么持久化,这些在纯控制台程序里永远碰不到的问题,会一次性砸到你脸上。

这份压缩包对应的典型场景是:你学过 C++ 基础语法,能写冒泡排序,能看懂 STL 的 vector,但一打开 Qt Creator 就不知道从哪下手。2048 的规则足够简单,简单到你可以把全部精力放在“Qt 怎么用”而不是“游戏逻辑怎么设计”上;同时它又足够完整,完整到你能跑出一个有界面、有交互、有状态管理的可执行程序。适合谁?适合刚学完 C++ 语法、想找一个能写进简历的小项目、又不想一上来就啃 Qt 官方文档的人。接下来我会按“先跑通、再拆解、再避坑、最后进阶”的顺序,把这份东西背后的技术路径讲清楚。

2. 用 Qt Widgets 搭出 2048 的最小可运行骨架:从 .pro 到第一个可编译窗口

2.1 为什么 2048 适合用 QWidget 而不是 QML

Qt 提供了两套 UI 方案:QML 和 Qt Widgets。对于 2048 这种固定布局、固定尺寸、不需要复杂动画和触摸手势的小游戏,QWidget 是更稳妥的选择。原因有三点:第一,QWidget 的坐标系和绘制机制是命令式的,你调用paintEvent就能直接画方块,不需要理解 QML 的声明式绑定;第二,QWidget 对键盘事件的处理更直接,keyPressEvent重写即可,不需要额外配置焦点策略;第三,初学者调试 QWidget 时,断点能直接打在 C++ 代码里,而 QML 的调试往往要跨语言栈。

常见做法是:主窗口继承QWidget,用一个QVector<QVector<int>>存 4x4 棋盘,重写paintEvent画格子,重写keyPressEvent处理方向键。下面是最小骨架的 .pro 文件和主窗口头文件。

# 2048.pro QT += core gui widgets TARGET = Game2048 TEMPLATE = app SOURCES += main.cpp mainwindow.cpp HEADERS += mainwindow.h
// mainwindow.h #ifndef MAINWINDOW_H #define MAINWINDOW_H #include <QWidget> #include <QVector> class MainWindow : public QWidget { Q_OBJECT public: explicit MainWindow(QWidget *parent = nullptr); protected: void paintEvent(QPaintEvent *event) override; void keyPressEvent(QKeyEvent *event) override; private: static const int SIZE = 4; QVector<QVector<int>> board; // 0 表示空格 int score; void initBoard(); void drawBlock(QPainter &p, int row, int col, int value); }; #endif

逻辑说明:board用嵌套 QVector 而不是原生二维数组,是因为 QVector 在 Qt 容器体系里和信号槽、迭代器配合更顺,后续如果要改成动态尺寸也更容易。SIZE声明为static const int,编译期常量,避免魔法数字散落各处。score单独存,不塞进 board 里,因为分数和棋盘状态的生命周期虽然一致,但职责不同,后面做持久化时分开处理更清晰。

参数说明:QT += core gui widgets是 Qt5 的标准写法,Qt6 里 widgets 模块依然存在但推荐用QT += widgets即可。如果你用的是 Qt 5.14.2 或 Qt 6.x,这个 .pro 都能直接跑。注意不要写成QT += core gui widgets quick,引入 quick 会拖慢编译且用不上。

2.2 棋盘初始化与 paintEvent 的绘制顺序

初始化棋盘时,我一般会随机在两个不同位置生成 2 或 4。这里有个容易翻车的点:随机数种子。C++ 的rand()如果不调srand(),每次运行结果一样;调了srand(time(nullptr)),在快速重启时又可能因为秒级时间戳相同而重复。Qt 里更稳的做法是用QRandomGenerator::global()。

#include <QRandomGenerator> void MainWindow::initBoard() { board = QVector<QVector<int>>(SIZE, QVector<int>(SIZE, 0)); score = 0; for (int i = 0; i < 2; ++i) { int r = QRandomGenerator::global()->bounded(SIZE); int c = QRandomGenerator::global()->bounded(SIZE); while (board[r][c] != 0) { r = QRandomGenerator::global()->bounded(SIZE); c = QRandomGenerator::global()->bounded(SIZE); } board[r][c] = (QRandomGenerator::global()->bounded(2) + 1) * 2; // 2 或 4 } }

bounded(SIZE)返回 [0, SIZE) 的整数,等价于rand() % SIZE但没有取模偏差。bounded(2) + 1得到 1 或 2,乘 2 后就是 2 或 4。这段代码里 while 循环理论上可能死循环,但 4x4 棋盘上只有两个格子被占,概率极低,实际项目中可以加一个重试上限,不过对于初学项目,保持简洁更重要。

paintEvent 的绘制顺序决定了视觉层次:先画背景网格,再画每个格子的数字块,最后画分数。顺序反了会出现数字被背景覆盖的玄学现象。下面是一个可用的绘制实现。

void MainWindow::paintEvent(QPaintEvent *event) { QPainter p(this); p.setRenderHint(QPainter::Antialiasing); int margin = 10; int blockSize = (width() - margin * (SIZE + 1)) / SIZE; for (int r = 0; r < SIZE; ++r) { for (int c = 0; c < SIZE; ++c) { int x = margin + c * (blockSize + margin); int y = margin + r * (blockSize + margin); drawBlock(p, r, c, board[r][c]); } } p.setPen(Qt::black); p.drawText(rect(), Qt::AlignTop | Qt::AlignHCenter, "Score: " + QString::number(score)); }

setRenderHint(QPainter::Antialiasing)让圆角方块边缘不锯齿,代价是轻微性能开销,2048 这种规模完全无感。blockSize根据窗口宽度动态计算,这样窗口拉伸时格子会跟着变,不需要手动调尺寸。drawText的rect()是整个窗口区域,AlignTop | AlignHCenter把分数放在顶部居中。注意drawBlock里要根据 value 选不同颜色,value 为 0 时画浅灰底,非 0 时按数值映射色相。

2.3 键盘事件与移动逻辑的第一次拆解

keyPressEvent是 2048 交互的入口。Qt 默认只有获得焦点的 widget 才接收键盘事件,主窗口作为顶层 widget 通常自动有焦点,但如果你在窗口里放了按钮或其他控件,焦点可能被抢走。保险做法是在构造函数里调用setFocusPolicy(Qt::StrongFocus)。

void MainWindow::keyPressEvent(QKeyEvent *event) { bool moved = false; switch (event->key()) { case Qt::Key_Up: moved = moveUp(); break; case Qt::Key_Down: moved = moveDown(); break; case Qt::Key_Left: moved = moveLeft(); break; case Qt::Key_Right: moved = moveRight(); break; default: return; } if (moved) { spawnRandomTile(); update(); // 触发重绘 } }

update()是 Qt 的异步重绘请求,不是立即调用 paintEvent,而是把重绘事件放进事件循环。如果你在移动逻辑里直接调repaint(),会强制同步重绘,在快速连按方向键时可能造成卡顿。moved标志位的作用是:只有棋盘真正发生变化时才生成新数字,否则按无效方向键也会冒出新块,这是新手最常见的逻辑 bug 之一。

移动逻辑本身可以抽象成一个slide函数:先把一行/一列的非零元素挤到一端,再合并相邻相同值,再挤一次。这个算法在四个方向上复用,只是遍历顺序不同。我一般会写一个slideLine(QVector<int> &line)处理单行,然后四个方向分别提取行或列传入。这样代码量少,且合并逻辑只写一遍,减少出错面。

3. 把 2048 的合并算法写对:四个方向、一次合并、分数累加的三个关键约束

3.1 单行滑动合并的算法边界

2048 的合并规则看起来简单,但有两个硬约束:一次移动中,同一个方块不能参与两次合并;合并后的值要累加到分数。很多初学者写出来的代码在[2, 2, 2, 2]这种行上会得到[8, 0, 0, 0],正确结果应该是[4, 4, 0, 0]。问题出在合并后没有跳过下一个元素。

bool slideLine(QVector<int> &line, int &scoreGain) { QVector<int> filtered; for (int v : line) { if (v != 0) filtered.append(v); } QVector<int> merged; int i = 0; while (i < filtered.size()) { if (i + 1 < filtered.size() && filtered[i] == filtered[i + 1]) { int val = filtered[i] * 2; merged.append(val); scoreGain += val; i += 2; // 关键:跳过已合并的两个元素 } else { merged.append(filtered[i]); i += 1; } } while (merged.size() < line.size()) { merged.append(0); } bool changed = (merged != line); line = merged; return changed; }

逻辑说明:先用filtered去掉所有 0,这样合并时不需要处理空格间隔。i += 2是防止[2,2,2,2]变成[8,0,0,0]的核心。合并完成后用 0 补齐到原长度。changed通过比较合并前后的 vector 得出,用于判断是否需要生成新块。

参数说明:scoreGain是引用传入,每次合并把新值累加进去。注意merged != line的比较在 QVector 上是逐元素比较,长度不同也会返回 true,所以补齐 0 之后再比较是安全的。

3.2 四个方向如何复用同一套逻辑

上、下、左、右四个方向的区别只在于:从哪一端开始读取、按行还是按列。我一般会写四个薄封装函数,每个函数负责提取对应的行/列,调用slideLine,再写回棋盘。

bool MainWindow::moveLeft() { bool anyChanged = false; for (int r = 0; r < SIZE; ++r) { QVector<int> line = board[r]; int gain = 0; if (slideLine(line, gain)) { board[r] = line; score += gain; anyChanged = true; } } return anyChanged; } bool MainWindow::moveRight() { bool anyChanged = false; for (int r = 0; r < SIZE; ++r) { QVector<int> line = board[r]; std::reverse(line.begin(), line.end()); int gain = 0; if (slideLine(line, gain)) { std::reverse(line.begin(), line.end()); board[r] = line; score += gain; anyChanged = true; } } return anyChanged; }

上下方向同理,只是提取的是列而不是行。这里有个性能上的小细节:QVector<int> line = board[r]会触发一次深拷贝,4x4 规模下无所谓,但如果以后扩展到更大棋盘,可以用引用加临时缓冲来避免。对于初学项目,可读性优先。

提示:std::reverse需要#include <algorithm>。Qt 自己也提供了std::reverse的替代,但标准库版本更通用。

3.3 分数累加与游戏结束判断

分数累加必须在slideLine返回 true 时才生效,否则无效移动也会加分。游戏结束的判断条件是:棋盘已满且四个方向上都没有相邻相同值。实现上可以写一个canMove()函数,遍历每个格子,检查右边和下边是否相等,同时检查是否有空格。

bool MainWindow::canMove() const { for (int r = 0; r < SIZE; ++r) { for (int c = 0; c < SIZE; ++c) { if (board[r][c] == 0) return true; if (c + 1 < SIZE && board[r][c] == board[r][c + 1]) return true; if (r + 1 < SIZE && board[r][c] == board[r + 1][c]) return true; } } return false; }

这个函数在每次生成新块后调用,返回 false 就弹出游戏结束提示。注意不要用QMessageBox阻塞式弹窗,否则窗口关闭后焦点可能丢失,后续按键无响应。更稳的做法是设置一个gameOver标志位,在 paintEvent 里画一层半透明遮罩加文字,按键事件里判断标志位直接返回。

4. 避坑与排查:2048qt小游戏C++初学阶段最容易翻车的 5 个地方

4.1 现象:编译通过但窗口一片空白,什么都不画

原因:paintEvent没有被触发,或者QPainter在构造时传了错误的 widget 指针。常见情况是主窗口没有调用show(),或者paintEvent里提前 return 了。另一个隐蔽原因是.pro文件里漏了QT += widgets,导致链接到了错误的库,程序能启动但绘制调用被忽略。

解决:在main.cpp里确认w.show()存在;在paintEvent第一行加qDebug() << "paint";看是否输出;检查.pro的 QT 行是否包含 widgets。如果用的是 Qt 6,还要确认 CMakeLists.txt 里find_package(Qt6 COMPONENTS Widgets REQUIRED)和target_link_libraries都写了。

4.2 现象:按方向键没反应,或者只有第一次有效

原因:焦点被其他控件抢走,或者keyPressEvent里没有调用event->accept()。Qt 的事件传播机制是:如果当前 widget 不处理某个事件,它会向上传给父 widget。如果主窗口里放了按钮,按钮会先拿到键盘事件。

解决:构造函数里setFocusPolicy(Qt::StrongFocus);在keyPressEvent的 default 分支里调用QWidget::keyPressEvent(event)而不是直接 return;如果确实有子控件,给子控件设置setFocusPolicy(Qt::NoFocus)。

4.3 现象:合并结果不对,[2,2,2,2]变成[8,0,0,0]

原因:合并时没有跳过已合并的元素,导致新生成的 4 又和下一个 2 合并。这是算法实现中最经典的血泪经验。

解决:在slideLine里合并后i += 2,而不是i += 1。同时确保先过滤 0 再合并,不要在原数组上边遍历边合并。

4.4 现象:窗口拉伸后格子错位或数字画到格子外面

原因:paintEvent里用了固定的 blockSize,没有根据当前窗口尺寸动态计算。或者drawText的矩形区域没有和格子对齐。

解决:每次paintEvent都重新计算blockSize = (width() - margin * (SIZE + 1)) / SIZE;drawText传入的矩形用QRect(x, y, blockSize, blockSize)而不是整个窗口区域。如果窗口宽高比不是 1:1,还要考虑取qMin(width(), height())来保持正方形。

4.5 现象:程序运行一段时间后崩溃,或者数字显示为乱码

原因:QVector越界访问,或者QString::number传入了未初始化的值。另一个常见原因是QRandomGenerator::global()在多线程环境下使用不当,但 2048 是单线程,这个概率低。乱码通常是因为源码文件编码和 Qt Creator 的默认编码不一致,中文注释或字符串变成乱码。

解决:在board[r][c]访问前确认 r、c 在 [0, SIZE) 范围内;所有QString字面量用QStringLiteral("...")包裹;在 Qt Creator 里把文件编码统一设为 UTF-8,并在.pro里加QMAKE_CXXFLAGS += -utf-8(MSVC)或确保编译器默认 UTF-8。

5. 从能跑到好用:给 2048 加存档、动画和自定义棋盘尺寸的进阶手法

5.1 用 QSettings 做分数持久化,避免每次重启归零

QSettings是 Qt 里最省事的持久化方案,跨平台,自动处理注册表和配置文件路径。在MainWindow构造函数里读取历史最高分,在游戏结束时写入。

// 构造函数中 QSettings settings("MyCompany", "Game2048"); highScore = settings.value("highScore", 0).toInt(); // 游戏结束时 if (score > highScore) { highScore = score; settings.setValue("highScore", highScore); }

参数说明:QSettings的前两个参数是组织名和应用名,决定了配置文件的存储位置。Windows 下会写到注册表HKEY_CURRENT_USER\Software\MyCompany\Game2048,Linux 下写到~/.config/MyCompany/Game2048.conf。如果你不想污染注册表,可以用QSettings settings(QSettings::IniFormat, QSettings::UserScope, "MyCompany", "Game2048")强制用 ini 文件。

5.2 用 QPropertyAnimation 给方块加移动动画

QWidget 本身不支持对自定义绘制内容做属性动画,但你可以把每个格子的位置抽成独立属性,或者用QVariantAnimation手动插值。更简单的做法是:在paintEvent里根据一个qreal progress变量计算偏移量,用QTimer驱动 progress 从 0 到 1。

// 头文件里加 QTimer *animTimer; qreal animProgress; // 构造函数里 animTimer = new QTimer(this); animProgress = 1.0; connect(animTimer, &QTimer::timeout, this, [this]() { animProgress += 0.1; if (animProgress >= 1.0) { animProgress = 1.0; animTimer->stop(); } update(); }); // 移动后启动 animProgress = 0.0; animTimer->start(16); // 约 60fps

逻辑说明:animProgress从 0 到 1 表示动画进度,paintEvent里根据这个值对每个方块的位置做线性插值。16ms 的定时器间隔接近 60fps,视觉上足够流畅。注意在动画进行中要屏蔽键盘输入,否则状态会错乱。

5.3 把棋盘尺寸从 4x4 改成可配置

SIZE目前是编译期常量,改成运行时变量需要把QVector<QVector<int>>的初始化、paintEvent的循环边界、canMove的遍历范围全部改成用成员变量。改动点不多,但容易漏。我一般会加一个int boardSize成员,在构造函数里从 QSettings 读取,默认 4。

boardSize = settings.value("boardSize", 4).toInt(); board = QVector<QVector<int>>(boardSize, QVector<int>(boardSize, 0));

然后在paintEvent里用boardSize替换SIZE,在slideLine里用line.size()而不是固定长度。这样改完之后,你可以在设置里让用户选 4x4、5x5 甚至 6x6,可玩性提升明显。

5.4 验证你的 2048 是否写对了:三个可复现的测试用例

写完代码后,不要靠感觉判断对错。手动构造几个棋盘状态,验证输出是否符合预期。下面三个用例覆盖了最常见的边界。

测试用例输入行期望输出验证点
连续相同[2, 2, 2, 2][4, 4, 0, 0]一次移动只合并一次
间隔相同[2, 0, 2, 4][4, 4, 0, 0]0 被正确跳过
无合并[2, 4, 8, 16][2, 4, 8, 16]不改变且返回 false

把这三个用例写成单元测试,用QTest或者简单的assert都行。每次改完合并逻辑跑一遍,比在界面上狂按方向键靠谱得多。

5.5 我踩过的最后一个坑:Qt 版本差异导致的 API 变化

Qt 5 和 Qt 6 在QPainter、QVector、QSettings上大部分兼容,但QVector在 Qt 6 里被QList取代了别名,QRandomGenerator的bounded签名也有细微差别。如果你从网上抄的代码编译报错,先确认 Qt 版本。我一般会在.pro里加greaterThan(QT_MAJOR_VERSION, 5): QT += core5compat来兼容部分旧 API,但更好的做法是直接查当前版本的文档。

写 2048 这个项目,最大的收获不是游戏本身,而是你被迫把 C++ 语法、Qt 事件循环、容器操作、调试手段串成了一条线。这条线走通了,后面做更复杂的 Qt 项目就不会再被“窗口怎么画”“按键怎么接”这种问题卡住。希望帮到你。

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

返回列表