资讯详情

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

📅 2026/10/25 11:24:44 | 华诺云谱 👁 阅读
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 而不是 QMLQt 提供了两套 UI 方案QML 和 Qt Widgets。对于 2048 这种固定布局、固定尺寸、不需要复杂动画和触摸手势的小游戏QWidget 是更稳妥的选择。原因有三点第一QWidget 的坐标系和绘制机制是命令式的你调用paintEvent就能直接画方块不需要理解 QML 的声明式绑定第二QWidget 对键盘事件的处理更直接keyPressEvent重写即可不需要额外配置焦点策略第三初学者调试 QWidget 时断点能直接打在 C 代码里而 QML 的调试往往要跨语言栈。常见做法是主窗口继承QWidget用一个QVectorQVectorint存 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; QVectorQVectorint 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 QVectorQVectorint(SIZE, QVectorint(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(QVectorint line)处理单行然后四个方向分别提取行或列传入。这样代码量少且合并逻辑只写一遍减少出错面。3. 把 2048 的合并算法写对四个方向、一次合并、分数累加的三个关键约束3.1 单行滑动合并的算法边界2048 的合并规则看起来简单但有两个硬约束一次移动中同一个方块不能参与两次合并合并后的值要累加到分数。很多初学者写出来的代码在[2, 2, 2, 2]这种行上会得到[8, 0, 0, 0]正确结果应该是[4, 4, 0, 0]。问题出在合并后没有跳过下一个元素。bool slideLine(QVectorint line, int scoreGain) { QVectorint filtered; for (int v : line) { if (v ! 0) filtered.append(v); } QVectorint 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) { QVectorint 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) { QVectorint 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; }上下方向同理只是提取的是列而不是行。这里有个性能上的小细节QVectorint 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)) / SIZEdrawText传入的矩形用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-8MSVC或确保编译器默认 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\Game2048Linux 下写到~/.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目前是编译期常量改成运行时变量需要把QVectorQVectorint的初始化、paintEvent的循环边界、canMove的遍历范围全部改成用成员变量。改动点不多但容易漏。我一般会加一个int boardSize成员在构造函数里从 QSettings 读取默认 4。boardSize settings.value(boardSize, 4).toInt(); board QVectorQVectorint(boardSize, QVectorint(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 项目就不会再被“窗口怎么画”“按键怎么接”这种问题卡住。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑