C++ Qt实现2048小游戏:核心算法与课设避坑指南
简介基于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还是裸数组为什么我选了QVector2048的棋盘只有4乘4共16个格子用int board[4][4]完全够用很多参考代码也是这么写的。但我在课设里更推荐QVectorQVectorint理由有四条。第一移动算法里不可避免要“抽出一行、去掉0、合并、补0、写回”这些操作涉及按值构造临时行、反转、拷贝赋值QVector提供了一整套现成接口代码能短三分之一。第二二维数组作为函数参数会退化成指针接口签名写起来很别扭QVector是Qt容器可以按值传递底层隐式共享拷贝代价很低也可以传const引用。第三QVector可以一行初始化成4x4全零棋盘QVectorQVectorint(4, QVectorint(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(QVectorint line); // 对一行做去零合并补零返回新增分数 bool boardsEqual(const QVectorQVectorint a, const QVectorQVectorint b) const; QVectorQVectorint m_board; int m_score; };几个接口设计上的参数说明。tileAt设计成只读访问器界面16个Label每帧刷新时调用它取数值。move返回bool表示这次按键是否真的改变了棋盘这个返回值在界面层可以做“没动就不刷新”的短路优化。spawnRandomTile是私有方法因为只有startNewGame和move内部需要调用它对外暴露意义不大反而容易让调用顺序出错。compressLine接受一个QVectorint引用直接修改传入的行新增分数通过返回值带出来这和算法实现里“合并”和“计分”同步进行的设计是对应的。信号槽方面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 QVectorQVectorint(BOARDSIZE, QVectorint(BOARDSIZE, 0)); m_score 0; spawnRandomTile(); spawnRandomTile(); emit boardChanged(); emit scoreChanged(m_score); } int GameModel::compressLine(QVectorint line) { // 第一步去掉所有0 QVectorint filtered; for (int v : line) { if (v ! 0) filtered.append(v); } // 第二步相邻相同合并注意只合并一轮 int gained 0; QVectorint 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) { QVectorQVectorint newBoard(BOARDSIZE, QVectorint(BOARDSIZE, 0)); int totalGained 0; bool moved false; for (int i 0; i BOARDSIZE; i) { QVectorint 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: /* ri, cj 保持不变 */ 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%概率生成210%概率生成4。void GameModel::spawnRandomTile() { QVectorQPairint, 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的最小尺寸窗口固定420x48060留给了分数栏和四周留白这样游戏窗口不会在玩家无意拖拽时变形。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插件没有显示服务器或使用嵌入式平台时需要linuxfbLinux帧缓冲插件。发行包没有把这个插件目录带过去或者运行时没有设置QT_QPA_PLATFORM环境变量Qt就找不到平台插件。解决分两步检查。第一步看你的Qt安装目录下plugins/platforms/里有没有libqlinuxfb.so没有就说明安装时没勾选对应组件重新运行Qt安装器补装第二步在运行脚本里显式指定# run.sh export QT_QPA_PLATFORMlinuxfb 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函数里直接调用 QVectorint 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 c17 # 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方案。希望帮到你。本文还有配套的精品资源点击获取

相关新闻

Claude Code打造求职自动化流水线:从JD解析到简历定制的完整实践

Claude Code打造求职自动化流水线:从JD解析到简历定制的完整实践

上个月我还在跟招聘软件搏斗,每天刷几十个岗位,投出去的简历像扔进黑洞。直到我在GitHub上刷到一个19K星的项目,思路一下子打通了:用Claude Code把自己求职流程里最耗时间的环节全部串起来,从岗位采集、JD解析、简历匹…

2026/9/24 21:50:58 阅读更多 →
安卓PS5模拟器实测:能跑但离“口袋PS5”还有多远?

安卓PS5模拟器实测:能跑但离“口袋PS5”还有多远?

最近几天数码圈和游戏圈同时被一个消息刷了屏——有团队放出了安卓端的PS5模拟器,名字一出来群就炸了,各路主播和搞机党连夜下载试跑。我也第一时间搞到手里实测了一轮。先说结论:它能跑,但跟你心里那个“口袋PS5”还差得很远。这…

2026/9/24 21:49:57 阅读更多 →
Deepseek Harness 框架解析:插件化与多智能体编排实战

Deepseek Harness 框架解析:插件化与多智能体编排实战

1. 从零认识 Deepseek Harness:它到底解决什么问题第一次听到 Deepseek Harness 这个名字,很多人会误以为它是某个新出的模型权重或者推理加速库。实际上,它是一套围绕大模型能力做“约束、编排、扩展”的运行时框架,核心定位是把…

2026/9/24 21:49:57 阅读更多 →

最新新闻

CoreDNS v1.8.0 离线部署实战:K8s 集群 DNS 恢复与调优指南

CoreDNS v1.8.0 离线部署实战:K8s 集群 DNS 恢复与调优指南

简介:coredns_v1.8.0.tar.gz 是面向 Kubernetes 集群运维与部署人员的 CoreDNS 镜像离线包,适用于 k8s v1.21.2 环境。当集群无法直接拉取外网镜像或需要固定版本时,可通过该包完成 CoreDNS 组件的本地导入与部署,解决内网环境下的…

2026/9/25 23:58:24 阅读更多 →
SharpDX Winform DX11窗口实战:消息循环与交换链对接

SharpDX Winform DX11窗口实战:消息循环与交换链对接

简介:这份源码资源面向具备一定C#基础、希望入门DirectX 3D图形开发的开发者,聚焦于在Winform环境中使用SharpDX搭建第一个可渲染的3D窗口。与常见示例不同,它没有依赖内置窗口系统,而是将Direct3D 11的渲染目标放入Panel控件中&a…

2026/9/25 23:58:24 阅读更多 →
CEF 110 非官方编译实战:开启 MP4/MP3 支持与避坑指南

CEF 110 非官方编译实战:开启 MP4/MP3 支持与避坑指南

简介:这是一份面向桌面应用开发者的 CEF 110.0.5481.180 Windows 64 位非官方编译包,适合需要在自有程序中嵌入 Chromium 内核、并直接播放 MP3、MP4 及 H.264 视频的开发者。相比官方默认构建,该版本补齐了多媒体编解码支持,可用…

2026/9/25 23:58:24 阅读更多 →
Atlas 300V 24G部署YOLOv5全流程:从AI推理加速卡到检测框

Atlas 300V 24G部署YOLOv5全流程:从AI推理加速卡到检测框

从“Atlas 300V 24G是运算加速卡吗”这个问题开始说起。我刚接触这张卡的时候也是在搜索框里输入了类似的话,毕竟名字里带着“300V”“24G”,又是插在服务器PCIe插槽上的一块大卡,很容易让人下意识拿它跟GPU比。拆开包装装进机器后你会发现&a…

2026/9/25 23:58:24 阅读更多 →
The Concise TypeScript Book 精读:函数返回类型推断(Type from Func Return)——原理、边界与进阶运用

The Concise TypeScript Book 精读:函数返回类型推断(Type from Func Return)——原理、边界与进阶运用

文档教程 【免费下载链接】typescript-book The Concise TypeScript Book: A Concise Guide to Effective Development in TypeScript. Free and Open Source. 项目地址: https://gitcode.com/gh_mirrors/typ/typescript-book 点击查看 免费下载 本篇技术指南以开源…

2026/9/25 23:58:24 阅读更多 →
RK3588 rkisp驱动开发指南:从摄像头出图到3A调优

RK3588 rkisp驱动开发指南:从摄像头出图到3A调优

简介:本资源为瑞芯微RK平台ISP驱动的源码包,面向从事Linux内核驱动开发、嵌入式视觉与摄像头调试的工程师及学习者,可用于理解RK ISP在V4L2框架下的设备注册、平台驱动匹配与图像源子设备实现。包内共17个文件,以7个C源文件与8个头…

2026/9/25 23:56:23 阅读更多 →

日新闻

AI元人文:从工具使用到思维重构的深度探索

AI元人文:从工具使用到思维重构的深度探索

最近半年我一直在琢磨一件事:AI元人文到底是什么?说白了,就是“用元视角重新审视人与AI的关系”,也在“探索AI如何反向逼着我们发现自己的思考边界”。标题里的“元探索”,在我看就是一层套一层的追问——当你用AI解决…

2026/9/25 0:00:41 阅读更多 →
Python+CNN车牌识别实战:从数据预处理到模型训练与部署

Python+CNN车牌识别实战:从数据预处理到模型训练与部署

简介:基于Python与卷积神经网络的车牌识别项目,面向计算机视觉初学者及智能交通开发者,目标是帮助用户掌握从数据预处理、模型构建到实际部署的完整流程。压缩包共25个文件,包含jpg/png图像样本、py训练脚本、md说明文档、dat数据…

2026/9/25 0:00:41 阅读更多 →
Vim基础操作全攻略:保存退出、模式切换与高频命令实战

Vim基础操作全攻略:保存退出、模式切换与高频命令实战

1. 项目概述1.1 核心需求解析今天聊聊Vim。写这个题目的原因是:几乎每个后端开发者、运维人员、数据工程师某天都会遇到一个场景——深夜加班,服务器登录界面只有黑底白字,编辑器只有vi/vim,你必须在五分钟内完成一次配置修改并保…

2026/9/25 0:00:41 阅读更多 →

周新闻

Flutter for OpenHarmony游戏卡片渐变背景实战:从原理到性能优化

Flutter for OpenHarmony游戏卡片渐变背景实战:从原理到性能优化

直接铺开项目本身吧。这几个月我一直在折腾一件事:用Flutter给OpenHarmony做一款游戏集合类的App,说白了就是把若干小游戏塞进一个壳里,用统一入口分发。这个方向本身不算新鲜,真正让我花了不少心思的,是首页那堆游戏卡…

2026/9/25 19:27:14 阅读更多 →
Word表格编号全攻略:从列表编号到题注交叉引用

Word表格编号全攻略:从列表编号到题注交叉引用

写Word文档,最让人头疼的往往是那些“看起来不起眼”的小问题。比如表格编号这事:今天在表后面多加了两个空白行,明天给客户交稿前发现整个章节的编号全部错位,光是挨个改序号就能耗掉大半个下午。我前阵子帮人整理一份上百页的技…

2026/9/25 11:15:26 阅读更多 →
从第一个站到第二个站:独立开发者的静态网站选型与落地实践

从第一个站到第二个站:独立开发者的静态网站选型与落地实践

1. 项目概述1.1 核心需求解析做独立开发者这几年,说实话,第一个网站上线的那天晚上我兴奋得没睡着。但等它跑了半年,流量惨淡、功能臃肿、代码自己都懒得看第二遍之后,我才慢慢琢磨明白一个道理:第一个网站是练手&…

2026/9/25 20:29:09 阅读更多 →

月新闻

持续集成 流水线自动化与 声明式交付 实践:原型怎样变成可用功能

持续集成 流水线自动化与 声明式交付 实践:原型怎样变成可用功能

持续集成 流水线自动化与 声明式交付 实践:原型怎样变成可用功能分类:[AI/大模型]细分主题:AI 增强型 CI/CD 流水线自动化与 GitOps 实践:Agent 工作流、工具调用与任务拆解:从原型到生产的验收清单很多团队在尝试用大…

2026/9/25 20:29:43 阅读更多 →
容器编排 生产环境运维与排障实战:复盘记录怎样真正派上用场

容器编排 生产环境运维与排障实战:复盘记录怎样真正派上用场

容器编排 生产环境运维与排障实战:复盘记录怎样真正派上用场分类:[工程技术]细分主题:Kubernetes 生产环境运维与排障实战:可复制的项目复盘模板与决策记录大部分团队的事故复盘报告,最后都变成了躺在 Confluence 或钉…

2026/9/25 20:29:31 阅读更多 →
容器 容器化技术与镜像安全管理:核心链路应该先拆哪一步

容器 容器化技术与镜像安全管理:核心链路应该先拆哪一步

容器 容器化技术与镜像安全管理:核心链路应该先拆哪一步分类:[工程技术]细分主题:Docker 容器化技术与镜像安全管理:核心链路的逐步实现与关键代码取舍面对一个积累了五六年历史包袱的单体架构应用(包含 Web 接口、后台…

2026/9/25 19:27:26 阅读更多 →