☰
C++ 2048qt 小游戏源码实战:Qt4 环境搭建与核心算法拆解
2026/10/21 2:45:30 网站建设 项目流程

简介:这是一份面向C++与Qt4初学者的2048小游戏实战源码,适合刚接触GUI编程、希望通过完整项目理解面向对象设计的学习者。项目以Qt4跨平台框架为基础,借助QGraphicsView与QGraphicsItem构建游戏界面,实现方块绘制与动态效果,并围绕GameBoard、Tile、GameManager等类组织棋盘状态维护、方块合并、得分记录与游戏结束判断等核心逻辑,同时演示信号与槽机制在组件通信中的应用。压缩包共6个文件,以2个cpp源文件、1个h头文件、1个ui界面文件、1个pro工程文件及1个user配置为主,整体约7KB,结构精简,便于直接编译运行与逐行研读。目前已有270人学习。通过这份代码,读者可掌握窗口创建、控件添加、用户输入处理与自定义图形绘制,并锻炼调试与问题排查能力,为进入游戏开发打下基础。

1. 从一份 2048qt 小游戏源码说起:C++ 初学者能拿它练什么

如果你刚学完 C++ 基础语法,指针、类、继承都过了一遍,但一打开 IDE 就不知道写什么,那这份 2048qt 小游戏源码值得你花一个下午拆一拆。它把 2048 的完整游戏逻辑和 Qt 的图形界面绑在一起,用 C++ 实现,代码量不大,但五脏俱全:棋盘数据结构、随机数生成、滑动合并算法、分数统计、键盘事件响应、界面重绘,一个都不少。对 C++ 初学者来说,它最大的价值不是“能玩”,而是“能改”——你可以把 4x4 改成 5x5,可以把数字换成图片,可以加撤销功能,每一步改动都能立刻在界面上看到反馈。Qt4 的信号槽机制、事件循环、QWidget 绘制流程,在这个项目里都有实际落点,比看文档敲示例代码扎实得多。下面我就按“先跑起来、再看结构、最后动手改”的顺序,把这份源码拆开讲一遍。

2. 环境准备与首次运行:把 Qt4 工程跑起来

2.1 确认 Qt4 开发环境是否就位

这份源码的关键词是 qt4,意味着它依赖的是 Qt4 那一套 API,不是 Qt5 或 Qt6。Qt4 和 Qt5 在信号槽语法、头文件路径、部分类名上有差异,直接拿 Qt5 打开大概率编译不过。常见做法是装一个 Qt4.8.7 的完整开发包,配合 qmake 构建。Windows 下可以用 MinGW 版本的 Qt4,Linux 下用发行版仓库里的 qt4-dev-tools 和 libqt4-dev。装完之后在终端敲qmake -v,能输出版本号就说明环境通了。如果你机器上同时有 Qt5 和 Qt4,注意用绝对路径调用 Qt4 的 qmake,别让环境变量指错了。

# 检查 Qt4 是否可用,输出应为 QMake version 2.x,Using Qt version 4.8.x qmake -v # 如果系统默认是 Qt5,用完整路径调用 Qt4 的 qmake /usr/local/Trolltech/Qt-4.8.7/bin/qmake -v

这里两个命令的区别在于:第一个走的是 PATH 里的默认 qmake,第二个直接指定 Qt4 安装目录下的 qmake。参数上没有什么可调的,关键是确认版本号是 4.x。如果输出的是 5.x 或 6.x,后面编译一定会报一堆头文件找不到的错。

2.2 用 qmake 生成 Makefile 并编译

Qt4 项目通常带一个 .pro 文件,这是 qmake 的工程描述文件。进到源码目录后,标准三步走:qmake 生成 Makefile,make 编译,运行可执行文件。如果源码里没有 .pro 文件,只有一堆 .cpp 和 .h,那就需要自己写一个最小的 .pro,把源文件列进去。

# 进入源码目录 cd 2048qt # 生成 Makefile qmake 2048qt.pro # 编译,-j4 表示四线程并行编译,加快速度 make -j4 # 运行生成的可执行文件,Windows 下是 .exe ./2048qt

qmake 这一步会根据 .pro 文件里的 SOURCES、HEADERS、QT 等变量生成平台相关的 Makefile。make 负责实际编译链接。如果编译报错说找不到 QApplication 或 QWidget,基本就是 qmake 用错了版本。运行阶段如果提示缺少 QtCore4.dll 或 libQtCore.so.4,说明动态库路径没配好,Windows 下把 Qt4 的 bin 目录加进 PATH,Linux 下用 ldconfig 刷新一下库缓存。

2.3 首次运行会看到什么

程序启动后是一个固定大小的窗口,中间是 4x4 的棋盘格,初始随机出现两个数字块,通常是 2 或 4。用方向键上下左右滑动,相同数字的块会合并,每次有效滑动后空位随机生成一个新块。分数显示在窗口顶部,当棋盘填满且无法再合并时弹出游戏结束提示。如果窗口一片空白或者直接闪退,先看终端有没有输出错误信息,常见的是资源文件路径不对或者 Qt 插件没加载。

提示:Qt4 程序在部分新系统上可能因为缺少平台插件而无法启动,终端会提示 “This application failed to start because it could not find or load the Qt platform plugin”。解决办法是把 Qt4 安装目录下的 plugins/platforms 路径加到环境变量 QT_PLUGIN_PATH 里。

3. 核心模块拆解:棋盘、合并算法与 Qt 界面怎么咬合

3.1 棋盘数据结构与初始化

2048 的核心是一个二维数组,通常用 4x4 的 int 矩阵表示,0 代表空格,其他值代表数字块。源码里一般会封装一个 Board 类或者直接在 MainWindow 里维护这个数组。初始化时全部置 0,然后调用两次随机生成函数,在空位里放上初始块。

// board.h 片段:棋盘数据与基本操作 class Board { public: static const int SIZE = 4; int cells[SIZE][SIZE]; // 0 表示空格 Board() { // 初始化全部为 0 for (int i = 0; i < SIZE; ++i) for (int j = 0; j < SIZE; ++j) cells[i][j] = 0; } // 在随机空位生成一个新块,90% 概率为 2,10% 概率为 4 bool spawn() { QVector<QPair<int,int>> empty; for (int i = 0; i < SIZE; ++i) for (int j = 0; j < SIZE; ++j) if (cells[i][j] == 0) empty.append(qMakePair(i, j)); if (empty.isEmpty()) return false; int idx = qrand() % empty.size(); int value = (qrand() % 10 == 0) ? 4 : 2; cells[empty[idx].first][empty[idx].second] = value; return true; } };

这段代码里,cells 是核心存储,SIZE 定死为 4,想改成 5x5 就改这个常量,但要注意界面绘制和合并逻辑里所有硬编码的 4 都要跟着换。spawn 函数先收集所有空位坐标,再用 qrand 随机挑一个,按 9:1 的比例决定放 2 还是 4。qrand 是 Qt4 的随机数函数,用之前最好调一次 qsrand 设种子,否则每次启动序列一样。参数上,value 的概率分布可以按需调整,比如改成 8:2 会让游戏更难。

3.2 滑动与合并算法:一行代码决定手感

2048 的滑动逻辑可以拆成三步:把非零元素往方向压缩、相邻相同则合并、再次压缩。以向左滑动为例,每一行独立处理。源码里通常写一个 slideLeft 函数,其他方向通过旋转棋盘复用同一套逻辑。

// 向左滑动一行,返回是否发生了移动或合并 bool slideLine(int line[SIZE], int& scoreGain) { bool moved = false; // 第一步:压缩,把非零元素移到前面 int tmp[SIZE] = {0}; int pos = 0; for (int i = 0; i < SIZE; ++i) { if (line[i] != 0) { tmp[pos++] = line[i]; } } // 第二步:相邻相同合并 for (int i = 0; i < pos - 1; ++i) { if (tmp[i] != 0 && tmp[i] == tmp[i+1]) { tmp[i] *= 2; scoreGain += tmp[i]; tmp[i+1] = 0; moved = true; } } // 第三步:再次压缩,填补合并后的空位 int final[SIZE] = {0}; pos = 0; for (int i = 0; i < SIZE; ++i) { if (tmp[i] != 0) { final[pos++] = tmp[i]; } } // 判断是否与原始行不同 for (int i = 0; i < SIZE; ++i) { if (line[i] != final[i]) { moved = true; line[i] = final[i]; } } return moved; }

这个函数是整个游戏手感的关键。第一步压缩把 [0,2,0,2] 变成 [2,2,0,0];第二步合并把 [2,2,0,0] 变成 [4,0,0,0] 并加分;第三步再压缩确保没有中间空位。scoreGain 是引用传参,合并产生的分数直接累加到总分。moved 标志决定这次滑动是否有效,只有有效滑动才生成新块,否则玩家按了方向键但棋盘没变化,不应该刷新。常见坑是合并时一个块只能参与一次合并,比如 [2,2,2,2] 向左应该变成 [4,4,0,0] 而不是 [8,0,0,0],上面代码里合并后把 tmp[i+1] 置 0 就避免了连锁合并。

3.3 Qt 界面绘制与键盘事件

界面部分继承 QWidget,重写 paintEvent 画棋盘和数字块,重写 keyPressEvent 处理方向键。Qt4 的信号槽在这里主要用来解耦游戏逻辑和界面刷新,比如逻辑层算完之后发一个 update 信号,界面层收到后调 update() 触发重绘。

// mainwindow.cpp 片段:键盘事件与重绘触发 void MainWindow::keyPressEvent(QKeyEvent *event) { bool moved = false; switch (event->key()) { case Qt::Key_Left: moved = board.slide(Direction::Left); break; case Qt::Key_Right: moved = board.slide(Direction::Right); break; case Qt::Key_Up: moved = board.slide(Direction::Up); break; case Qt::Key_Down: moved = board.slide(Direction::Down); break; default: QWidget::keyPressEvent(event); return; } if (moved) { board.spawn(); // 有效滑动后生成新块 update(); // 触发 paintEvent 重绘 if (board.isGameOver()) { QMessageBox::information(this, "Game Over", QString("最终得分:%1").arg(board.score())); } } } void MainWindow::paintEvent(QPaintEvent *event) { QPainter painter(this); // 绘制背景和网格 for (int i = 0; i < Board::SIZE; ++i) { for (int j = 0; j < Board::SIZE; ++j) { QRect rect = cellRect(i, j); // 计算每个格子的像素区域 int value = board.cells[i][j]; painter.fillRect(rect, colorForValue(value)); if (value > 0) { painter.setPen(Qt::black); painter.drawText(rect, Qt::AlignCenter, QString::number(value)); } } } }

keyPressEvent 里先判断按键类型,调对应的滑动函数,只有 moved 为真才生成新块并重绘。update() 是 Qt 的异步重绘请求,不会立刻执行 paintEvent,而是等事件循环空闲时统一处理。paintEvent 里用 QPainter 逐格绘制,cellRect 根据行列算像素坐标,colorForValue 根据数字返回不同背景色。这里如果窗口大小固定,cellRect 可以直接用常量计算;如果想支持窗口缩放,就得在 resizeEvent 里重新算格子尺寸。

注意:Qt4 的 qrand 和 qsrand 在头文件 QtGlobal 里,不是标准库的 rand。如果混用会导致随机序列不可控。另外 paintEvent 里不要做耗时操作,否则界面会卡。

4. 避坑与排查:编译不过、闪退、手感不对怎么办

4.1 编译报错 “QApplication: No such file or directory”

现象是 qmake 之后 make 直接失败,提示找不到 QApplication 头文件。原因通常是 .pro 文件里没有写QT += widgets或者QT += gui,Qt4 里 QApplication 属于 QtGui 模块,需要在 .pro 里显式声明。解决方法是打开 .pro 文件,确认有QT += core gui这一行,如果用的是 Qt4.8 以上,widgets 模块已经合并进 gui,不用单独加。改完重新 qmake 再 make。

4.2 程序启动后窗口一闪而过

现象是双击可执行文件或者终端运行后,窗口出现一瞬间就消失。原因可能是程序在构造函数里就抛了异常,或者 paintEvent 里访问了空指针导致崩溃。排查方法是把程序放到终端里运行,看有没有输出错误信息;如果没有任何输出,就在 main 函数开头加日志,逐步定位。常见的是资源文件路径写成了绝对路径,换台机器就找不到,改成相对路径或者用 Qt 资源系统嵌入。

4.3 方向键按了没反应

现象是窗口正常显示,但按方向键棋盘不动。原因通常是焦点问题,QWidget 默认不接收键盘事件,需要调用setFocusPolicy(Qt::StrongFocus)或者在构造函数里setFocus()。另一个可能是 keyPressEvent 里没有处理对应的 Qt::Key_Left 等枚举,或者被父控件拦截了。解决方法是确认焦点策略设置正确,并在 keyPressEvent 末尾对未处理的按键调用父类实现。

4.4 合并逻辑出现“连锁合并”

现象是 [2,2,2,2] 向左滑动后变成了 [8,0,0,0],而不是预期的 [4,4,0,0]。原因是合并时没有把已合并的块标记为不可再合并,导致同一个块在一次滑动里参与了多次合并。解决办法是在合并循环里,合并完一对之后跳过下一个位置,或者像 3.2 节代码那样把被合并的位置置 0,并在循环里判断非零才处理。

4.5 分数累加不对或游戏结束判断失效

现象是合并后分数没变,或者棋盘满了但没弹出结束提示。分数问题一般是 scoreGain 没有正确累加到总分,检查引用传参有没有写错。游戏结束判断需要遍历所有格子,如果还有空位或者存在相邻相同数字,就不能算结束。常见错误是只判断了空位,忘了检查相邻可合并的情况,导致明明还能滑动却提前结束。

5. 二次开发上手:从改参数到加功能的完整思路

5.1 先改配置再动逻辑

拿到源码后,我一般先找常量定义的地方,把棋盘大小、初始块数量、2 和 4 的出现概率、窗口尺寸这些参数集中到一个头文件里。这样改起来不用满项目搜数字。比如把 SIZE 从 4 改成 5,需要同步改的地方包括:cells 数组维度、paintEvent 里的循环边界、cellRect 的格子尺寸计算、游戏结束判断的遍历范围。改完编译运行,如果界面错位,多半是 cellRect 里的除数没跟着换。

// config.h:集中管理可调参数 namespace Config { const int BOARD_SIZE = 4; // 棋盘边长,改成 5 就是 5x5 const int INITIAL_TILES = 2; // 初始块数量 const int WIN_VALUE = 2048; // 胜利目标值 const int SPAWN_4_PROB = 10; // 生成 4 的概率(百分比) const int CELL_SIZE = 80; // 每个格子的像素边长 const int GAP = 10; // 格子间距 }

把参数抽出来之后,改棋盘大小只需要动 BOARD_SIZE 一个地方,其他模块引用 Config::BOARD_SIZE 即可。SPAWN_4_PROB 控制难度,调成 20 会让游戏明显变难。CELL_SIZE 和 GAP 影响窗口总尺寸,改完记得同步调整窗口的固定大小或者改成可缩放。

5.2 加一个撤销功能

撤销是 2048 里最实用的扩展功能。思路是在每次有效滑动前,把当前棋盘状态和分数存进一个栈里,按 Ctrl+Z 时弹出栈顶恢复。Qt4 里可以用 QStack 存一个自定义结构体,包含 cells 数组和 score。

// 在 MainWindow 里增加撤销栈 struct GameState { int cells[Config::BOARD_SIZE][Config::BOARD_SIZE]; int score; }; QStack<GameState> undoStack; // 滑动前保存状态 void MainWindow::saveState() { GameState state; memcpy(state.cells, board.cells, sizeof(board.cells)); state.score = board.score(); undoStack.push(state); if (undoStack.size() > 20) undoStack.removeFirst(); // 限制栈深度 } // Ctrl+Z 恢复 void MainWindow::undo() { if (undoStack.isEmpty()) return; GameState state = undoStack.pop(); memcpy(board.cells, state.cells, sizeof(board.cells)); board.setScore(state.score); update(); }

saveState 在每次 moved 为真之后、spawn 之前调用,保证存的是滑动前的状态。undoStack 限制 20 步,防止内存无限增长。undo 函数恢复棋盘和分数后调 update 重绘。键盘事件里加一个 Ctrl+Z 的判断即可。这个功能代码量不大,但能让你理解状态快照和恢复的套路,很多游戏里的存档读档都是类似思路。

5.3 用样式表美化界面

Qt4 支持 QSS 样式表,可以像写 CSS 一样改控件外观。虽然 2048 主要是自绘,但窗口背景、按钮、消息框都可以用样式表统一风格。在 main 函数里加载一个 qss 文件,或者直接在代码里 setStyleSheet。

// main.cpp 里加载样式表 QFile styleFile(":/style.qss"); if (styleFile.open(QFile::ReadOnly)) { QString style = QLatin1String(styleFile.readAll()); qApp->setStyleSheet(style); }

qss 文件里可以写QWidget { background-color: #faf8ef; }之类的规则。注意自绘的 paintEvent 里用的颜色不受样式表影响,需要手动改 colorForValue 的返回值。样式表适合统一调整非自绘部分,自绘部分还是得改代码。

5.4 验证改动是否正确的几个检查点

每次改完,我习惯按这个顺序过一遍:编译有没有警告、启动后初始块是不是两个、四个方向滑动是否都正常、合并后分数有没有加对、棋盘满了能不能正确结束、撤销能不能恢复到上一步。这几个点覆盖了核心逻辑,任何一个不对都说明改动引入了回归。特别是改棋盘大小之后,边界条件最容易出问题,比如 5x5 时游戏结束判断的遍历范围如果还写 4,就会漏判。

提示:改完逻辑后,可以临时把 spawn 的概率改成 100% 生成 2,这样更容易构造测试用例,验证合并和结束判断。测完再改回来。

5.5 从这份源码继续往下走

这份 2048qt 源码的价值在于它足够小,小到你可以在一个下午里把每一行都读懂,又足够完整,完整到包含了游戏开发的几个核心环节:状态管理、输入响应、渲染循环、分数系统。我当初第一次拆它的时候,在合并算法那里卡了很久,总觉得 [2,2,2,2] 应该合并成 [8],后来才想明白一次滑动里每个块只能合并一次。从那以后我每次写类似逻辑,都会先拿纸画一遍状态转移,确认没有连锁反应再动手。如果你能把这份源码跑通、改通,再去看更复杂的 Qt 项目,会发现很多套路是相通的。希望这份拆解能帮到你,源码就在那里,跑起来改起来,比只看不练强得多。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询