简介:基于QT框架完成的2048小游戏完整课程设计资料,面向学习C++与GUI编程的高校学生,可作为高级语言程序设计大作业参考。项目采用int[4][4]数组管理棋盘,涵盖初始化得分与清空格子、随机生成数字2、检测空格及游戏结束逻辑、paintEvent绘制界面等核心模块,代码结构清晰,便于理解事件响应与界面刷新机制。压缩包共7个文件,包含2个cpp源文件、1个头文件、1个ui界面文件、1个工程配置文件及1份项目报告docx,整体仅45KB,轻量易用。目前已有330人学习下载。资料附带项目报告,详细说明设计思路与实现过程,适合需要快速搭建类似小游戏或撰写课程报告的同学参考。
1. 2048这个C++大作业,真正的难点不在界面,而在那套合并规则
很多人拿到“基于QT实现2048小游戏”这个题目,第一反应是去调界面,把格子颜色做得花里胡哨,结果答辩时老师随便按几下方向键,屏幕上出现“2 2 4”却被你合并成“8”的诡异结果,整个项目瞬间翻车。基于Qt的C++版2048,核心价值不在窗口多漂亮,而在于你能否把移动、合并、随机生成、判死这套规则,用C++的类、容器和事件机制组织得干净、可测、能讲。这篇笔记按实际做课设的顺序来写:先定数据结构与接口,再写核心算法,最后才碰界面,中间穿插编译和运行时的踩坑记录。适合正在选C++大作业题目的学生,也适合想把Qt事件驱动和标准容器操作一起复习一遍的开发者。
2. 动手前先把类和接口定死:MVC拆分的取舍与接口签名
2.1 棋盘模型用QVector还是裸数组:为什么我选了QVector
2048的棋盘只有4乘4共16个格子,用int board[4][4]完全够用,很多参考代码也是这么写的。但我在课设里更推荐QVector<QVector<int>>,理由有四条。
第一,移动算法里不可避免要“抽出一行、去掉0、合并、补0、写回”,这些操作涉及按值构造临时行、反转、拷贝赋值,QVector提供了一整套现成接口,代码能短三分之一。第二,二维数组作为函数参数会退化成指针,接口签名写起来很别扭;QVector是Qt容器,可以按值传递(底层隐式共享,拷贝代价很低),也可以传const引用。第三,QVector可以一行初始化成4x4全零棋盘:QVector<QVector<int>>(4, QVector<int>(4, 0)),裸数组还得写双重循环去清零。第四,答辩时老师经常问“能不能扩展成5x5”,用const int BOARDSIZE = 4管理维度,改一个常量就行,裸数组需要连带改所有循环边界。
有人会担心QVector比裸数组慢。实际上对于16个int的规模,两者性能差异在硬件计时器上都测不出来,键盘操作每秒也就几次,完全感知不到。课设的性能瓶颈从来不在棋盘存储,而在动画写太重导致刷新卡顿,这一点后面避坑章节会细说。
2.2 GameModel的接口骨架:move、isGameOver、spawnRandomTile先定下来
写代码前先把类设计写清楚,不要边写边加成员。我这里采用一个GameModel类专职管游戏逻辑,不碰任何QWidget相关代码;界面另用一个MainWindow类,只负责显示和键盘转发。先看头文件:
// GameModel.h #pragma once #include <QObject> #include <QVector> #include <QPair> class GameModel : public QObject { Q_OBJECT public: enum Direction { Up, Down, Left, Right }; static const int BOARDSIZE = 4; explicit GameModel(QObject* parent = nullptr); void startNewGame(); // 清空棋盘,重新开局 bool move(Direction dir); // 尝试移动,返回棋盘是否发生变化 bool isGameOver() const; // 棋盘满且无相邻相同块 int tileAt(int row, int col) const; // 只读访问某个格子的值 int score() const { return m_score; } signals: void boardChanged(); // move成功后发射,通知界面刷新 void scoreChanged(int newScore); // 分数变化时发射 void gameOver(); // 判死时发射 private: void spawnRandomTile(); // 在随机空位生成2或4 int compressLine(QVector<int>& line); // 对一行做去零+合并+补零,返回新增分数 bool boardsEqual(const QVector<QVector<int>>& a, const QVector<QVector<int>>& b) const; QVector<QVector<int>> m_board; int m_score; };几个接口设计上的参数说明。
tileAt设计成只读访问器,界面16个Label每帧刷新时调用它取数值。move返回bool,表示这次按键是否真的改变了棋盘,这个返回值在界面层可以做“没动就不刷新”的短路优化。spawnRandomTile是私有方法,因为只有startNewGame和move内部需要调用它,对外暴露意义不大,反而容易让调用顺序出错。compressLine接受一个QVector<int>&引用,直接修改传入的行,新增分数通过返回值带出来,这和算法实现里“合并”和“计分”同步进行的设计是对应的。
信号槽方面,boardChanged让界面在移动成功后统一刷新16个格子,scoreChanged只负责分数Label的更新,gameOver在判死时弹出提示。这样的切分在报告里也能讲清楚:界面是显示层,Model是业务层,信号槽是两者之间的松耦合桥梁。
2.3 MainWindow只管刷新与转发:信号槽在这里比直接调用干净在哪
MainWindow在构造时接收一个GameModel*指针,通过connect把三个信号分别连接到自己的刷新槽函数。这里值得注意的是:不需要在MainWindow里定义任何游戏逻辑函数,它拿到的永远只是“模型已经变了,你来刷新显示”。
对比一下不这么做的写法:很多代码会把棋盘数组直接放在MainWindow里,键盘事件里直接操作数组,再把结果画到界面上。这种写法的问题是,键盘事件函数会越来越长,先判断方向,再处理移动,再处理合并,再判断结束,再刷新界面,最后在报告里你根本没法把“游戏规则”单独拎出来讲。而拆出GameModel之后,你可以写一个独立的小测试程序,不创建窗口,直接调model.move(Left),用qDebug()打印棋盘,验证合并规则对不对。这种可测试性在答辩时就是你最大的底气。
信号槽相比直接函数调用还有一个好处:将来你想加一个“AI自动演示”功能,只需要在另一个对象里调用model.move(),界面通过槽函数自动跟着刷新,不需要让AI模块持有MainWindow的指针。对于大作业这种规模,这可能用不上,但接口一旦定成这样,扩展起来确实顺手。
3. 2048核心算法手把手实现:移动、合并、随机生成与判死逻辑
3.1 先把方向的映射剥开:上下左右统一走“取行->压缩->写回”
2048的移动规则,本质是对每行或每列执行同一个压缩操作:去掉空位、相邻相同合并、后面补零。四个方向的区别只在于“从哪个方向取数据”。我建议把方向映射单独处理,而不是为每个方向写四个几乎一样的函数。
// GameModel.cpp 部分代码 #include "GameModel.h" #include <QRandomGenerator> void GameModel::startNewGame() { m_board = QVector<QVector<int>>(BOARDSIZE, QVector<int>(BOARDSIZE, 0)); m_score = 0; spawnRandomTile(); spawnRandomTile(); emit boardChanged(); emit scoreChanged(m_score); } int GameModel::compressLine(QVector<int>& line) { // 第一步:去掉所有0 QVector<int> filtered; for (int v : line) { if (v != 0) filtered.append(v); } // 第二步:相邻相同合并,注意只合并一轮 int gained = 0; QVector<int> merged; for (int i = 0; i < filtered.size(); ++i) { if (i + 1 < filtered.size() && filtered[i] == filtered[i + 1]) { merged.append(filtered[i] * 2); gained += filtered[i] * 2; ++i; // 消耗掉被合并的那个格子 } else { merged.append(filtered[i]); } } // 第三步:后面补0凑齐4个 while (merged.size() < BOARDSIZE) merged.append(0); line = merged; return gained; }compressLine是整个游戏算法的心脏。它假定传入的line是一个长度为4的数组,已经按照“从头部往尾部”的方向排列好了。比如按左方向时,一行原始数据是{2, 0, 2, 4},压缩后变成{4, 4, 0, 0},新增分数4。如果按右方向,我们需要把同一行反向取出来作为line,压缩后再反向写回。
合并循环里++i这行是关键。它保证了一次操作中每个格子最多参与一次合并,也就是{2, 2, 2, 2}会变成{4, 4, 0, 0},而不是连锁合并成{8, 0, 0, 0}。这个细节如果丢了,后面第5章的避坑部分会专门展开。
3.2 四个方向统一处理:move函数的坐标映射
方向映射最简单的实现,是把“取出某一行”和“写回某一行”分别做成两个小函数,或者直接在move里用switch控制行列坐标。我的做法是用一个双重循环,外层固定按行遍历,内层按方向映射取坐标。
bool GameModel::move(Direction dir) { QVector<QVector<int>> newBoard(BOARDSIZE, QVector<int>(BOARDSIZE, 0)); int totalGained = 0; bool moved = false; for (int i = 0; i < BOARDSIZE; ++i) { QVector<int> line; line.reserve(BOARDSIZE); // 根据方向从棋盘取出一行/一列数据 for (int j = 0; j < BOARDSIZE; ++j) { int r = i, c = j; switch (dir) { case Up: r = j; break; case Down: r = BOARDSIZE - 1 - j; break; case Left: /* r=i, c=j 保持不变 */ break; case Right: c = BOARDSIZE - 1 - j; break; } line.append(m_board[r][c]); } int gained = compressLine(line); totalGained += gained; // 压缩后的数据写回newBoard for (int j = 0; j < BOARDSIZE; ++j) { int r = i, c = j; switch (dir) { case Up: r = j; break; case Down: r = BOARDSIZE - 1 - j; break; case Left: /* 不变 */ break; case Right: c = BOARDSIZE - 1 - j; break; } newBoard[r][c] = line[j]; } } if (newBoard != m_board) { m_board = newBoard; m_score += totalGained; emit boardChanged(); emit scoreChanged(m_score); return true; } return false; }这里比较重要的一点是newBoard != m_board的整盘比较。QVector重载了相等运算符,逐元素比较,省去手写boardsEqual的麻烦,头文件里那个私有方法也就不需要了。如果移动前后棋盘完全相同,说明本次按键不产生任何有效移动,此时不需要生成新方块,逻辑上保持棋盘不变就行,这也符合原版2048的行为。
另一个细节:在写回newBoard之前,newBoard初始是全零,所以compressLine补的0会自然落在正确的位置上。上移方向取出的是每一列从上到下,压缩后写回同一列,相当于把每个数字向上“滑”到底。下移则是从下往上取行,压缩后写回原列,模拟向下滑动。四个方向只改内层循环的坐标映射,压缩逻辑完全不碰。
3.3 随机数字的生成:QRandomGenerator的使用与弃用qrand的原因
每次有效移动之后,需要在空白格里生成一个新方块,原版规则是90%概率生成2,10%概率生成4。
void GameModel::spawnRandomTile() { QVector<QPair<int, int>> emptyCells; for (int r = 0; r < BOARDSIZE; ++r) { for (int c = 0; c < BOARDSIZE; ++c) { if (m_board[r][c] == 0) { emptyCells.append({r, c}); } } } if (emptyCells.isEmpty()) return; int idx = QRandomGenerator::global()->bounded(emptyCells.size()); int value = (QRandomGenerator::global()->bounded(10) == 0) ? 4 : 2; m_board[emptyCells[idx].first][emptyCells[idx].second] = value; emit boardChanged(); }QRandomGenerator::global()->bounded(n)返回[0, n-1]区间内的随机整数,线程安全,不需要自己管理种子。不要再用qsrand和qrand的组合,这两个函数在Qt 5.15里虽然还能用,但已经标记为弃用,Qt 6直接移除。有些旧教程教的qsrand(QTime::currentTime().msec())这种种子写法,在现代Qt里属于拉低代码印象分的旧习惯。bounded(10) == 0表示十分之一的概率生成4,反过来说就是九成概率生成2,和原版2048一致。
补充一个审题层面的细节:startNewGame里连续调用两次spawnRandomTile,保证开局有两个数字。两次调用之间没有额外延迟,这是线程安全的,因为QRandomGenerator是全局单例,内部有锁。
3.4 判死要不要每次移动后都做:isGameOver的两种触发时机
游戏结束条件有两个:棋盘16格全部填满,且任意相邻两格都不相等。注意是“上下左右相邻”,不是斜对角,也不是整行整列相同才算。
bool GameModel::isGameOver() const { for (int r = 0; r < BOARDSIZE; ++r) { for (int c = 0; c < BOARDSIZE; ++c) { if (m_board[r][c] == 0) return false; // 还有空位 if (c + 1 < BOARDSIZE && m_board[r][c] == m_board[r][c + 1]) { return false; // 左右相邻可合并 } if (r + 1 < BOARDSIZE && m_board[r][c] == m_board[r + 1][c]) { return false; // 上下相邻可合并 } } } return true; }调用时机放在move()返回true之后、界面刷新过程中判断。也就是MainWindow的键盘事件里,移动成功后先刷新棋盘,再判断是否结束。不要在每个spawnRandomTile里判死,因为开局生成两个数字后棋盘还剩14个空位,此时判死没有意义,只会白白消耗性能。
这里有个容易忽略的场景:玩家按了一次方向键,但盘面没有变化。此时move返回false,不需要生成新方块,也不需要判死,因为棋盘状态和按键之前完全一致。如果项目里做了“连续按同方向键”的误触保护,就依赖这个返回值来短路。
4. 用Qt Widgets把棋盘画出来:QGridLayout、keyPressEvent与分数联动
4.1 QGridLayout加16个QLabel,比QPainter自绘更适合课设
有不少同学看到网上一些炫酷版本用QPainter在paintEvent里绘制整个棋盘,然后为了处理窗口缩放重新计算方块坐标,调试一下午,画出来的方块边缘还对不齐。课设阶段我的建议是:用QGridLayout摆16个QLabel,每个Label负责显示一个格子的数字和背景色,简单直接。
// MainWindow.h 关键成员 class MainWindow : public QMainWindow { Q_OBJECT public: explicit MainWindow(GameModel* model, QWidget* parent = nullptr); protected: void keyPressEvent(QKeyEvent* event) override; private slots: void updateBoard(); // 刷新16个格子的显示 void updateScore(int score); // 更新分数Label private: GameModel* m_model; QLabel* m_scoreLabel; QLabel* m_tiles[GameModel::BOARDSIZE][GameModel::BOARDSIZE]; };构造函数的布局部分,可以贴在MainWindow.cpp的初始化列表之后:
// MainWindow.cpp 构造与布局 MainWindow::MainWindow(GameModel* model, QWidget* parent) : QMainWindow(parent), m_model(model) { auto* central = new QWidget(this); auto* outerLayout = new QVBoxLayout(central); m_scoreLabel = new QLabel("分数: 0", central); m_scoreLabel->setAlignment(Qt::AlignCenter); outerLayout->addWidget(m_scoreLabel); auto* grid = new QGridLayout; grid->setSpacing(8); for (int r = 0; r < GameModel::BOARDSIZE; ++r) { for (int c = 0; c < GameModel::BOARDSIZE; ++c) { m_tiles[r][c] = new QLabel("", central); m_tiles[r][c]->setAlignment(Qt::AlignCenter); m_tiles[r][c]->setMinimumSize(90, 90); grid->addWidget(m_tiles[r][c], r, c); } } outerLayout->addLayout(grid); setCentralWidget(central); setFixedSize(420, 480); connect(m_model, &GameModel::boardChanged, this, &MainWindow::updateBoard); connect(m_model, &GameModel::scoreChanged, this, &MainWindow::updateScore); setFocusPolicy(Qt::StrongFocus); updateBoard(); }setSpacing(8)控制格子间距,视觉上比默认的0间距舒服。每个Label设了90x90的最小尺寸,窗口固定420x480(60留给了分数栏和四周留白),这样游戏窗口不会在玩家无意拖拽时变形。setFixedSize一步锁死,省得写resizeEvent。
4.2 重写keyPressEvent:方向键与WASD双配的写法
键盘事件的焦点问题是个典型的坑。QMainWindow默认不会主动获得键盘焦点,如果界面上还有按钮或别的控件,方向键会先落在那个控件上,你的keyPressEvent压根不会触发。解决办法是构造函数的最后调用setFocusPolicy(Qt::StrongFocus),然后在窗口显示后调用setFocus()。
void MainWindow::keyPressEvent(QKeyEvent* event) { GameModel::Direction dir; bool handled = true; switch (event->key()) { case Qt::Key_Up: case Qt::Key_W: dir = GameModel::Up; break; case Qt::Key_Down: case Qt::Key_S: dir = GameModel::Down; break; case Qt::Key_Left: case Qt::Key_A: dir = GameModel::Left; break; case Qt::Key_Right: case Qt::Key_D: dir = GameModel::Right; break; default: handled = false; } if (handled) { bool moved = m_model->move(dir); if (moved && m_model->isGameOver()) { QMessageBox::information(this, "游戏结束", "棋盘已满且无法继续合并"); } } else { QWidget::keyPressEvent(event); } }这里handled变量用来区分“方向键被处理”和“其他按键交给基类默认处理”。WASD方案并不是原版2048有的,但很多同学笔记本上开着中文输入法,方向键会被输入法拦截,这时WASD就是救命方案。注意如果QMessageBox弹出后焦点转移,窗口需要再次调用setFocus()才能继续接收键盘事件,可以用QMessageBox::information的返回值处理完后再设一次焦点。
4.3 分数与界面的刷新链路:信号连接与统一刷新
updateBoard()槽函数负责把16个Label全部重刷一遍。数字为0时显示空字符串,非0时显示数字本身,同时根据数值用QSS切换背景色。
void MainWindow::updateBoard() { for (int r = 0; r < GameModel::BOARDSIZE; ++r) { for (int c = 0; c < GameModel::BOARDSIZE; ++c) { int value = m_model->tileAt(r, c); QLabel* lbl = m_tiles[r][c]; if (value == 0) { lbl->setText(""); lbl->setStyleSheet("background-color: #bbada0;" "border-radius: 8px;"); continue; } lbl->setText(QString::number(value)); // 数字越大颜色越深,这是2048玩家熟悉的一套色板 QString bg; switch (value) { case 2: bg = "#eee4da"; break; case 4: bg = "#ede0c8"; break; case 8: bg = "#f2b179"; break; case 16: bg = "#f59563"; break; case 32: bg = "#f67c5f"; break; case 64: bg = "#f65e3b"; break; default: bg = "#edcf72"; break; } lbl->setStyleSheet( QString("background-color: %1; border-radius: 8px;" "font-size: 28px; font-weight: bold;") .arg(bg)); } } } void MainWindow::updateScore(int score) { m_scoreLabel->setText(QString("分数: %1").arg(score)); }每次都重建整个StyleSheet字符串,在16个Label的规模下性能完全没问题,不用做什么缓存优化。颜色值沿用2048经典配色,答辩时一眼就能认出是这个游戏。128以上的数字统一用#edcf72深金色,省得为2048单独配一个颜色。字体大小固定28px,四位数的“1024”在90x90的格子里也能放得下。
5. Qt 5.15踩坑与排查:版本混用、linuxfb插件、合并规则与焦点问题
这一章专门写编译和逻辑调试中容易翻车的五个点。每一条都是我先看到现象、再定位原因、最后给出解决办法的真实过程,按顺序查就能解决大部分问题。
5.1 兼容性翻车:fatal: cannot mix incompatible qt library (version ex50601) with this library
现象:编译C++/Qt项目时报错,提示中带ex50601字样,说当前库和另一个Qt库版本不兼容。常见于Windows上装了多个Qt版本,或者用CMake时把系统路径的Qt头文件和自带Qt的lib文件混在一起了。
原因:Qt5.15.2对应的版本宏是QT_VERSION_STR = "5.15.2",如果头文件是5.15.2、链接库是5.12的,或者反过来,链接器立刻报错。ex50601这个数字串其实是版本编码,50601表示5.6.1,说明某处有一条旧版Qt库混了进来。最常见的情形是:系统环境变量PATH里先写了另一个Qt的bin目录,你在命令行里直接qmake时用的是旧版,而mingw32-make用的是新版。
解决:先确认当前Qt的安装版本,命令行执行qmake -v,看输出是否和你构建用的版本一致。在Qt Creator里检查构建套件(Kit)的Qt版本和编译器是否配对。如果是从旧的.pro文件打开项目,删掉build-开头的构建目录,执行“清理项目”后再重新构建。还有CMake用户,检查CMAKE_PREFIX_PATH是否指向了你真正想用的Qt安装目录,不要依赖系统默认路径。
5.2 编译通过运行黑匣子:Could not find the Qt platform plugin "linuxfb"
现象:代码编译顺利通过,在Linux服务器或嵌入式板卡上运行可执行文件,弹出qt.qpa.plugin: Could not find the Qt platform plugin "linuxfb" in ...之后进程崩溃。窗口没有出现,终端里只留下这一行错误。
原因:Qt的窗口系统插件是运行时动态加载的。桌面Linux上用xcb插件,没有显示服务器或使用嵌入式平台时需要linuxfb(Linux帧缓冲)插件。发行包没有把这个插件目录带过去,或者运行时没有设置QT_QPA_PLATFORM环境变量,Qt就找不到平台插件。
解决:分两步检查。第一步看你的Qt安装目录下plugins/platforms/里有没有libqlinuxfb.so,没有就说明安装时没勾选对应组件,重新运行Qt安装器补装;第二步在运行脚本里显式指定:
# run.sh export QT_QPA_PLATFORM=linuxfb export QT_QPA_PLATFORM_PLUGIN_PATH=/opt/Qt/5.15.2/gcc_64/plugins/platforms ./2048_game如果目标是部署到嵌入式设备,linuxfb之外还有eglfs可选,后者利用GPU渲染,性能更好,但需要板子有对应的显示驱动支持。课设阶段先用linuxfb跑通就行,不要一次性追求复杂渲染。
5.3 合并规则写错的翻车现场:{2,2,4}被算成了8
现象:游戏过程中,一行显示2 2 4,按一次左移后变成8 0 0 0,而不是标准的4 4 0 0。越玩分数涨得越离谱,但很多人玩到一半才发现。
原因:压缩算法在发现filtered[0] == filtered[1]后立即合并,合并后的结果4又被拿去和后面的4比较,发生了连锁合并。也就是说,合并循环里没有“每个格子只参与一次合并”的限制。标准2048的规则是一行内的同值合并只做一轮,{2, 2, 4}只能把前两个2合并成4,然后这个新4和原来的4并排放在一起,不能再合并成8。
解决:回到第3章的compressLine,合并循环里命中相等分支后执行++i,跳过一个格子,确保合并后的块不参与本轮后续比较。这段逻辑需要单独验证,不要通过手工玩游戏来试,写一个临时测试函数最稳:
// 临时验证代码,可在main函数里直接调用 QVector<int> test = {2, 2, 4}; GameModel model; int gained = model.compressLine(test); qDebug() << test; // 期望输出 {4, 4, 0, 0} qDebug() << gained; // 期望输出 4{2, 2, 2, 2}的用例也要测,正确结果应该是{4, 4, 0, 0},新增分数8,而不是{8, 0, 0, 0}。这类逻辑bug在报告里写“单元测试”章节时,是很好的素材。
5.4 方向键无效的焦点问题:setFocusPolicy忘了设
现象:运行程序后鼠标点击窗口里的格子区域,方向键完全没有反应;但如果点击的是窗口标题栏,再按方向键偶尔有用。加了按钮控件后,焦点被按钮吃掉的概率更高。
原因:QMainWindow默认的focusPolicy是Qt::NoFocus,键盘事件不会直接派发给窗口。窗口上有任何子控件时,焦点停留在某个子控件上,按键事件被发往该控件,而不是窗口。你重写的keyPressEvent根本没被调用。
解决:构造函数里加一行setFocusPolicy(Qt::StrongFocus),窗口就能主动获取键盘焦点,然后在show()之后调用setFocus()。如果界面上有按钮(比如“重新开始”按钮),点击按钮后焦点会跑到按钮上,此时方向键又失效了。处理办法是给按钮的点击槽函数末尾重新调用setFocus():
void MainWindow::onRestartClicked() { m_model->startNewGame(); setFocus(); // 把焦点抢回来,否则下一次方向键会失灵 }另一种更省心的方案是在keyPressEvent里不调用QWidget::keyPressEvent(event),而是直接event->accept()吞掉所有按键事件,但这样按钮的快捷键会受影响,不推荐。
5.5 中文乱码细节:MSVC下的/utf-8选项
现象:在Windows上用MSVC编译器编译,源代码里写的中文字符串(比如“游戏结束”“重新开始”)在界面上显示成乱码。用MinGW编译器则一切正常。
原因:MSVC默认把源文件按本地代码页(GBK)解析,而Qt Creator保存文件时默认是UTF-8编码。两者不一致,字符串字面量在内存里就成了错误字节序列。
解决:最稳妥的办法是在.pro文件里给MSVC加编译选项,强制按UTF-8解析源文件:
# 2048.pro QT += core gui greaterThan(QT_MAJOR_VERSION, 4): QT += widgets TARGET = 2048_game TEMPLATE = app CONFIG += c++17 # MSVC下强制UTF-8源码解析,避免中文乱码 msvc { QMAKE_CXXFLAGS += /utf-8 } SOURCES += \ main.cpp \ GameModel.cpp \ MainWindow.cpp HEADERS += \ GameModel.h \ MainWindow.h如果用的是CMake,等价写法是给target_compile_options添加$<$<CXX_COMPILER_ID:MSVC>:/utf-8>。顺带注意:所有源文件统一用UTF-8保存,不要在Qt Creator的设置里把默认编码改成GBK,否则换到Linux服务器上编译又会出现一串警告。
6. 从能玩到拿高分:悔棋、最高分存档和项目报告的写法
课设做到能玩只是及格,想让答辩时老师眼前一亮,可以在这三个方向上各加一点:一步悔棋、最高分持久化、以及一份能和代码对得上的报告。悔棋的实现思路很简单:在move调用前把整盘数据快照保存到m_lastBoard,再在需要悔棋时把快照覆盖回去。最高分用QSettings存到本地注册表或配置文件,程序重启后仍能读取。第三步是和项目报告配合的边界设计。
// 悔棋的完整实现要点 void GameModel::saveSnapshot() { m_lastBoard = m_board; // QVector隐式共享,赋值开销极小 m_lastScore = m_score; } bool GameModel::undo() { if (m_lastBoard.isEmpty()) return false; // 没有历史记录 m_board = m_lastBoard; m_score = m_lastScore; m_lastBoard.clear(); // 悔棋一次后清空历史,防重复撤销 emit boardChanged(); emit scoreChanged(m_score); return true; }调用顺序上,move()内部在改动棋盘之前先调用saveSnapshot(),因此玩家每次有效移动都会保存一份“移动前”的状态。undo只能回退一步,二次撤销直接返回false,避免玩家无限悔棋。
QSettings记录最高分只需要两个调用。写入放在scoreChanged信号的处理里,如果当前分数大于已存最高分就更新文件。读取在MainWindow构造时加载一次,显示在分数Label右侧。这段代码不要和游戏逻辑纠缠,单独放在界面层里。
项目报告的写法,我建议按这个结构组织,每个章节都能在代码里找到对应物:
| 报告章节 | 对应代码或设计 |
|---|---|
| 需求分析 | 游戏规则描述:移动、合并、随机生成2/4、结束条件 |
| 总体设计 | GameModel和MainWindow两个类的职责划分,信号槽关系图(文字描述即可) |
| 详细设计 | compressLine的算法流程、方向映射switch、spawnRandomTile随机策略 |
| 测试与运行 | 键盘操作说明、WASD附加方案、第5章的单元测试用例表 |
| 总结 | 遇到的问题与解决过程,挑踩坑章节里的一条写够500字 |
答辩时老师最常问的三个问题,提前准备答案:“为什么用QVector不用数组”、“2048判死条件怎么写的”、“合并会不会连锁”。这三个问题在这篇笔记对应的章节里都有答案,用自己的话复述一遍即可。我当年就是把动画特效做得太复杂,结果DEMO时玩家AI自动移动一次,刷新卡了半秒,反而被老师指出效率问题。如果你也想加动画,控制在“数字弹出”这一层就够了,不要为每个格子做滑动补间,性能上不适合QLabel方案。希望帮到你。
本文还有配套的精品资源,点击获取