Qt C++期末大作业:斗地主游戏从工程骨架到AI实现指南
简介面向C与Qt初学者的课程设计资源以单机版斗地主小游戏为载体完整覆盖期末大作业中常见的游戏逻辑、图形界面与电脑AI设计。资源共119个文件压缩包大小5.76MB包含12个cpp源文件、11个头文件、3个ui界面布局文件以及工程配置pro/qrc和90张png图片素材既便于查看功能拆分与模块划分也能直接运行还原项目效果。已有98人学习使用。项目将洗牌、发牌、选牌、出牌、计分面板和玩家控制等拆分为独立模块并实现了电脑玩家出牌策略适合用来理解Qt窗口程序组织方式也可借鉴单机斗地主的完整实现思路。对于需要完成C期末大作业或初步接触Qt游戏开发的学习者是一份结构清晰、可直接参考的完整示例也可作为课程设计报告的配套代码来研读。1. 期末大作业选 Qt 做斗地主先把这 3 个决定做了再写代码C 期末大作业如果只报一个项目名“Qt 实现单机版斗地主小游戏”绝对是高频选项。听起来常规实际能做到演示流畅、代码干净、答辩能讲清的同学比例并不高。多数人翻车在三个地方牌型判断写到一半发现分支越列越多、界面代码和游戏逻辑全塞在 MainWindow 里、AI 出牌只会拆牌乱打。这三个问题不是代码量不够是动工前三个决定没做对用 Qt Widgets 还是 QML、规则逻辑放哪个类、AI 做到什么难度。这篇笔记把这套方案的工程骨架、牌型规则、界面交互、简单 AI 和避坑点完整拆开按着做500 行左右就能交一个老师挑不出毛病的版本。2. 工程骨架与类设计Card / Player / GameEngine 怎么拆才不粘成一坨2.1 先定选型Qt Widgets 比 QML 更适合期末大作业我见过不少同学一上来就想用 Qt QML 做炫酷动画结果光动画状态就调了两周。期末大作业的评分重点从来不在特效在 C 逻辑和软件工程习惯。常见做法是选 Qt WidgetsQWidget 那一套理由有三条一是 QPushButton、QLabel、QGridLayout 这些控件文档多、报错直观msvc2019_64 编译链下几乎不会遇到 QML 那种运行时白屏问题二是信号槽在 Widgets 里更好讲答辩时老师问“界面怎么和逻辑通信”你直接指代码里的 connect 就能答清楚三是 QML 里的 JavaScript 逻辑会稀释 C 的工作量对大作业来说反而是减分项。版本上我建议用 Qt 5.15.2 或 5.12 系列清华源有离线包配 Qt Creator MSVC 编译套件。不是不能用 Qt 6但很多教材、博客和学长留下的示例都基于 Qt 5期末这段时间出问题能搜到的答案更多时间成本差不少。MVVM 框架那些高级玩法留给工作以后期末别给自己加戏。2.2 三个核心类怎么拆规则永远不和界面混在一起拆类的基本原则就一句话界面是皮肤规则是大脑数据是血液。我一般会拆成三个类加一个入口Card一张牌只装花色和点数两个 int 字段Player一个玩家装手牌、角色标记、是否地主GameEngine游戏裁判负责发牌、牌型识别、大小比较、胜负判定。// card.h #pragma once #include QMetaType struct Card { int suit; // 0黑桃, 1红桃, 2梅花, 3方块 int point; // 3~10, 11J, 12Q, 13K, 14A, 152, 16小王, 17大王 bool operator(const Card other) const { return point other.point; } }; Q_DECLARE_METATYPE(Card)// player.h #pragma once #include QVector #include card.h class Player { public: QVectorCard handCards; // 手牌初始 17 张地主 20 张 bool isLandlord false; // 是否地主 int role 0; // 0自己, 1下家, 2上家 void addCards(const QVectorCard cards); void removeCards(const QVectorCard cards); QVectorCard findPassCards(); // 按点数从小到大排序 };这里有两个设计细节值得说。第一Card 用值语义而不是 new 出来的指针手牌直接用QVectorCard这样发牌、洗牌、拷贝都交给 Qt 容器管理不会出现new 的 Card 忘了 delete这种期末最常见的血泪问题。第二point 字段从 3 编到 17也就是把 2 和大小王放在最顶端后续牌型比较直接比 point 大小不用查表换算 J、Q、K、A这是很多教程没点破的小技巧。// gameengine.h #pragma once #include QObject #include QVector #include player.h class GameEngine : public QObject { Q_OBJECT public: enum CardType { Invalid, Single, Pair, Triple, TripleWithOne, TripleWithTwo, Straight, PairChain, Plane, Bomb, Rocket }; explicit GameEngine(QObject* parent nullptr); void initDeck(); // 生成 54 张牌 void shuffleAndDeal(Player* p1, Player* p2, Player* p3); CardType judgeType(const QVectorCard cards) const; bool canBeat(const QVectorCard last, const QVectorCard current) const; private: QVectorCard m_deck; };GameEngine 继承 QObject 是为了后面用它发信号通知界面更新状态比如该谁出牌了但注意它不 include 任何 QWidget 头文件。这是整篇代码里最不能破的规矩GameEngine 永远不知道界面上有什么界面通过 connect 去监听它。一旦这个依赖反了改界面就要动规则改规则就要动界面最后就是两头扯皮。2.3 pro 工程文件与 main.cpp最小可编译骨架期末用 Qt Creator 新建项目时选 Qt Widgets Application会自动生成 main.cpp 和 MainWindow 的壳。pro 文件里通常只需要下面这几行别手贱乱加模块。QT core gui greaterThan(QT_MAJOR_VERSION, 4): QT widgets TARGET DouDiZhu TEMPLATE app SOURCES \ main.cpp \ gameengine.cpp \ player.cpp \ mainwindow.cpp HEADERS \ gameengine.h \ player.h \ mainwindow.hmain.cpp 最省心的写法是不动 UI 壳直接起一个 MainWindow#include QApplication #include mainwindow.h int main(int argc, char *argv[]) { QApplication a(argc, argv); MainWindow w; w.show(); return a.exec(); }一个常被忽略的问题是 pro 文件里的编码选项。MSVC 2019 默认按本地代码页读源文件如果你的 cpp 文件是 UTF-8 无 BOM里面写中文注释或字符串会乱码甚至编译报警。常见做法是在 pro 里加一行QMAKE_CXXFLAGS /utf-8这是我在 Qt 5.15.2 MSVC 组合下验证过最省事的一行比每次用记事本转码靠谱得多。3. 牌型判断与大小比较斗地主核心规则的手写实现不依赖任何黑匣子3.1 牌型枚举与编码方案把规则翻译成一眼能读懂的整数斗地主的牌型看着多归纳起来就十一种单张、对子、三条、三带一、三带二、顺子、连对、飞机、炸弹、火箭再加一个不是合法牌型。我的做法是先定义枚举再写一个 judgeType 函数把出牌序列映射成{ 类型, 关键点数, 附带点数 }三件套后续比较大小全部基于这个三件套不用再碰原始牌。这里的关键点数是决定大小的那个牌力值。比如三带一单张或对子的点数就是关键点数带的牌只做合法性校验不参与比大小顺子取最大点飞机取最长那组三条的点数。炸弹和火箭单独处理它们不仅能压普通牌型炸弹之间还按点数互压火箭压一切。// gameengine.cpp 中 judgeType 的核心计数逻辑 // 思路先统计每个点数的出现次数再按次数分布去匹配牌型 CardType GameEngine::judgeType(const QVectorCard cards) const { int n cards.size(); if (n 0) return Invalid; // 用点数做下标统计每种点数出现几次 int cnt[18] {0}; for (const Card c : cards) { cnt[c.point]; } // 把出现次数不为 0 的点数收集起来按点数降序 QVectorint points; for (int p 17; p 3; --p) { if (cnt[p] 0) points.push_back(p); } // 单张 / 对子 / 三条直接按 size 判断 if (n 1) return Single; if (n 2) return (points.size() 1) ? Pair : Rocket; // 大小王 if (n 3 points.size() 1) return Triple; // 两条判断其中一条是两张另一条是 2 if (n 4) { if (points.size() 1 cnt[points[0]] 4) return Bomb; if (points.size() 2 cnt[points[0]] 3) return TripleWithOne; } // 顺子5 到 12 张点数连续且每种只出现一次 if (n 5 n 12) { bool straight true; for (int i 0; i points.size() - 1; i) { if (points[i] - points[i1] ! 1) { straight false; break; } } if (straight points.size() n) return Straight; } // 连对3 个及以上连续对子 if (n 6 n % 2 0) { bool pairChain true; for (int p : points) { if (cnt[p] ! 2) { pairChain false; break; } } if (pairChain points.size() * 2 n) return PairChain; } return Invalid; }这段代码第一次写会漏很多边界我建议你复制后自己跑几组用例34567、334455、333444、2222、大王小王、3334。注意两个细节一是顺子不能包含 2 和大小王上面代码因为 points 从 17 往下收集天然会把 2 排除在顺子外2 的点数 15 不参与连续比较逻辑但保险起见你可以在 straight 分支里加一个points[0] 14的判断二是火箭必须单独用 size2 点数 16/17 判断不能走 Pair 分支否则王炸会被当对子。3.2 比较大小先比类型再比关键点数别拿整副牌比canBeat 是斗地主规则的第二个核心。它的逻辑一句话讲完同牌型比关键点数炸弹压所有普通牌型火箭压炸弹不同且非炸弹关系直接返回 false。我习惯把关键点数单独抽成一个函数因为三带一、顺子、飞机这些牌型不能简单取第一张牌比较。// 提取关键点数对子取牌点顺子取最大点 int GameEngine::keyPoint(const QVectorCard cards, CardType type) const { int cnt[18] {0}; for (const Card c : cards) cnt[c.point]; switch (type) { case Single: return cards[0].point; case Pair: for (int p 3; p 17; p) if (cnt[p] 2) return p; case Triple: for (int p 3; p 17; p) if (cnt[p] 3) return p; case TripleWithOne: for (int p 3; p 17; p) if (cnt[p] 3) return p; case TripleWithTwo: for (int p 3; p 17; p) if (cnt[p] 3) return p; case Straight: return cards[0].point cards[cards.size()-1].point ? cards[0].point : cards[cards.size()-1].point; case PairChain: return cards[0].point cards[1].point ? cards[0].point : cards[1].point; default: return 17; // Bomb 和 Rocket 各自特殊处理 } } bool GameEngine::canBeat(const QVectorCard last, const QVectorCard current) const { CardType lastType judgeType(last); CardType curType judgeType(current); if (lastType Invalid || curType Invalid) return false; if (curType Rocket) return true; // 火箭压一切 if (lastType Rocket) return false; if (curType Bomb lastType ! Bomb) return true; // 炸弹压普通 if (lastType Bomb curType ! Bomb) return false; if (lastType curType) { return keyPoint(current, curType) keyPoint(last, lastType); } return false; }这套实现有个好处牌型识别和大小比较完全解耦。后面你加飞机带翅膀这类牌型只需要扩展 judgeTypecanBeat 几乎不用动。参数上唯一要注意的是Straight的关键点必须取最大点因为34567和45678比较的是 5 和 8不是 4 和 5。3.3 测试先行规则代码写完先跑控制台用例再连界面我踩过最深的坑就是界面和规则一起写最后出 bug 不知道是信号槽的问题还是 judgeType 的问题。正确顺序是把 GameEngine 先写成不依赖 UI 的纯逻辑用一段临时 main 测过去再往上盖界面。你可以建一个test_rule.cpp手动编译或者直接在 main 里写几行断言测通再删。这一步花 20 分钟后面省的时间远远不止。// 临时测试代码验证完就删 #include QCoreApplication #include gameengine.h #include QDebug int main(int argc, char *argv[]) { QCoreApplication a(argc, argv); GameEngine engine; QVectorCard singleA, singleB; // 构造两张单牌A 和 2 singleA.push_back({0, 14}); // A singleB.push_back({1, 15}); // 2 qDebug() 判断单牌类型: (engine.judgeType(singleA) GameEngine::Single); qDebug() A 能不能管住 2: engine.canBeat(singleA, singleB); // false qDebug() 2 能不能管住 A: engine.canBeat(singleB, singleA); // true return 0; }这个测试文件的价值在于它把规则和白盒验证彻底绑死。你之后改任何函数跑一遍这几条断言就知道有没有破坏旧逻辑。期末答辩演示之前我会把这类临时测试文件留一份单独的版本名字叫rule_check.cpp老师问你怎么保证规则是对的时直接现场跑给他看。4. 界面与人机Qt 信号槽出牌交互和一个不蠢的简单 AI4.1 界面布局三家人和一张桌面用布局器而不是手写坐标Widgets 界面不用花哨但要信息清楚。我常用的布局是顶部一个 QLabel 显示下家出牌区中间一行是你的手牌区左侧上家出牌区右侧空地放底牌和操作按钮。核心控件的层级关系是QGridLayout套三个QHBoxLayout底部画你手牌的 17 个 QPushButton每个按钮对应一张 Card中间是你的出牌区用一组 QLabel 显示已经打出去的牌按钮固定宽度 40 像素高度 60 像素。手牌按钮上显示的文字用QString(♠%1).arg(pointText(c.point))这种格式花色和点数组合。点一下按钮要能选中我用动态属性实现选中时按钮背景变色再点一下取消。不需要继承 QPushButton直接setStyleSheet切换背景色就行期末够用。// mainwindow.cpp 中初始化手牌按钮的代码 void MainWindow::setupHandCards() { QLayout* oldLayout ui-handCardsArea-layout(); if (oldLayout) { QLayoutItem* item; while ((item oldLayout-takeAt(0)) ! nullptr) { delete item-widget(); delete item; } } for (int i 0; i m_selfPlayer-handCards.size(); i) { const Card c m_selfPlayer-handCards[i]; QPushButton* btn new QPushButton(this); btn-setProperty(cardIndex, i); btn-setText(cardText(c)); btn-setFixedSize(40, 60); connect(btn, QPushButton::clicked, this, MainWindow::onHandCardClicked); // 手牌区是 QHBoxLayout按顺序加进去即可 qobject_castQHBoxLayout*(ui-handCardsArea-layout())-addWidget(btn); } }逻辑说明这里每次重新布局手牌时先把旧按钮删干净是为了避免出牌后手牌减少但按钮残留。setProperty(cardIndex, i)是给按钮挂一个索引点击时通过它找到对应的 Card。参数说明固定 40x60 是为了 17 张牌在一行放得下如果你的窗口宽度不够 17*40680 像素就把按钮缩到 34x54或者把窗口默认宽度设到 900。4.2 出牌交互点击选牌 - 出牌 / 不出选牌和出牌的状态流转是期末最容易写乱的部分。我管理的状态只有三层等待出牌、选牌中、已出牌。点击手牌按钮时如果当前不是你的回合直接忽略点击。回合状态由 GameEngine 维护界面只做展示。// 点击手牌按钮的槽函数 void MainWindow::onHandCardClicked() { QPushButton* btn qobject_castQPushButton*(sender()); if (!btn || m_currentTurn ! 0) return; // 不是自己的回合就忽略 int idx btn-property(cardIndex).toInt(); bool selected btn-property(selected).toBool(); btn-setProperty(selected, !selected); // 刷新按钮样式选中变红未选恢复默认 btn-setStyleSheet(selected ? : background-color: #ffd700;); btn-setProperty(selected, !selected); } // 点“出牌”按钮 void MainWindow::onPlayCardsClicked() { QVectorCard selected; QListQPushButton* btns ui-handCardsArea-findChildrenQPushButton*(); for (QPushButton* b : btns) { if (b-property(selected).toBool()) { int idx b-property(cardIndex).toInt(); selected.push_back(m_selfPlayer-handCards[idx]); } } if (m_game-playCards(0, selected)) { // 出牌成功从手牌移除、刷新界面、轮到下一家 m_game-nextTurn(); } else { QMessageBox::warning(this, 出牌不合法, 请重新选择牌型); } }两个细节值得讲第一判断回合通过m_currentTurn ! 0实现0 表示出牌人数组里的下标这个值在 GameEngine 里维护界面代码只读它。第二出牌合法性判定完全在 GameEngine::playCards 里做它内部先调用 judgeType 再调用 canBeat和上一章的规则代码完全复用。UI 里只负责把选中的牌打包传过去如果返回 false 就弹提示不做任何规则判断。4.3 AI 策略先出最少牌数、再管牌、炸弹不到最后不出单机版斗地主的人机不能太聪明也不能傻到被老师一眼看出来。我用的策略是常见做法先分类统计手牌按单张最多先出、把牌拆成对子/三条减少散牌的贪心逻辑出牌。管牌时从手牌里找最小的能压倒上一手牌的牌型如果没有就过。炸弹和火箭只在两种情况下使用对方只剩 1 张或 2 张牌时无脑炸自己手牌快出完时冲一冲。// 简单的 AI 出牌函数找到最小能压住 last 的牌型 QVectorCard AiPlayer::findBeat(const QVectorCard hand, const QVectorCard last) { cardType lastType m_engine-judgeType(last); if (lastType GameEngine::Rocket) return {}; // 王炸压不住 if (lastType GameEngine::Bomb) { // 找比对方更大的炸弹 for (int p 15; p 17; p) { QVectorCard bomb getCardsOfPoint(hand, p, 4); if (!bomb.isEmpty()) return bomb; } return {}; } // 普通牌型按同样类型找最小可管 int key m_engine-keyPoint(last, lastType); for (int p key 1; p 17; p) { QVectorCard cand getCardsOfPoint(hand, p, needCount(lastType)); if (!cand.isEmpty() typeMatch(cand, lastType, p)) { return cand; } } return {}; }这个 AI 大约三十行效果是会管牌但不聪明对期末演示刚刚好。参数说明needCount根据牌型返回需要的张数单张 1、对子 2、三带一 4……真实斗地主的顺子处理可以比这个更精细但期末做到单张/对子/三条/导弹正确即可。需要强调的是 AI 的计算量极少几十个 QVector 遍历一下就是微秒级直接在 UI 线程跑不会有卡顿感千万别为了 AI 去开 QThread那会引入一堆跨线程信号的问题。5. 避坑Qt 编译、乱码、假死与内存泄漏的 5 个典型翻车现场5.1 现象Qt 下载慢 / qmake 版本不对导致构建失败期末季大家一窝蜂去官网下 Qt速度慢到怀疑人生。解决方案是走清华源下载离线安装包选qt-opensource-windows-x86-5.15.2.exe这类带 MSVC 的版本。装好后配置 Qt Creator 的构建套件时一定要选对编译器看到 MSVC2019 64bit 而不是 MinGW。我用 MinGW 编过同样代码信号槽没区别但如果你之后要接一些只发布 msvc 版 .lib 的库就傻眼了。如果打开项目时 qmake 报dependent ..\..\qt\5.15.2\msvc2019_64\include\qtwidgets 不存在基本都是路径含中文或系统用户名含空格把 Qt 和项目放到纯英文路径下重试。5.2 现象中文注释或按钮文字编译后显示乱码MSVC 2019 加 Qt 5 的经典组合默认按 GBK 读取源文件你的 cpp 如果存的是 UTF-8中文字符串全部变成锟斤拷。原因上面提过一句解决办法两种第一pro 文件加QMAKE_CXXFLAGS /utf-8让 MSVC 统一按 UTF-8 解析第二所有人把所有源文件转成 UTF-8 with BOM。我强烈选第一种因为第二种换台电脑或者团队协作拷贝文件时 BOM 会被 Git 或编辑器弄丢。另外QString 里写中文字面量时用QStringLiteral(开始游戏)它能在编译期把字符串转成 UTF-16比裸tr(开始游戏)更稳也顺手解决了乱码问题。5.3 现象connect 写了但槽函数不执行期末最常见的一个假死 bug。现象是按钮点了没反应、出牌后界面没动静。原因通常有三个第一槽函数名拼写和 signal 对不上而且你用的connect(sender, SIGNAL(clicked()), this, SLOT(onXxx()))老式写法运行时拼错也不报错第二sender()为空因为你 connect 时用了this-button但那个 button 已经被 delete 了第三多线程里跨线程 connect 默认是队列连接对象没有事件循环就丢消息。我的应对方案是统一用 C 11 的函数指针语法connect(btn, QPushButton::clicked, this, MainWindow::onHandCardClicked)。这写法的好处是编译期检查信号或槽不存在直接编译报错不给运行时留机会。排查时先在槽函数第一行qDebug() slot triggered如果执行到了再逐步往下看如果连这行都没打出来就确认是不是另一个对象在发信号。5.4 现象点按钮后界面卡死窗口拖不动新手最容易写出的死循环是while (true)等待事件比如在出牌按钮里写while (!m_engine-waitForNext()) {}来轮询。Qt 界面是事件驱动的你在槽函数里忙等事件循环就被卡死整个窗口表现为假死。就算你的 AI 计算再快一个QThread::sleep(3)加在槽函数里也会让画面冻住 3 秒。正确做法是用QTimer控制回合切换。我常写一个QTimer::singleShot(800, this, MainWindow::onTurnChanged)给玩家 800 毫秒看清上家出了什么牌再决定出牌或过。倒计时这类需求也应该用QTimer每次超时发一个信号而不是在循环里累加。5.5 现象程序退出时崩溃或内存泄漏如果你的代码严格遵守第 2 章的规范手牌全部用QVectorCard存储你几乎没有需要手动 delete 的地方。但如果你用了new QPushButton然后手动塞进布局记得delete。有一个通用检查法在 main 函数最后一行加QApplication::setQuitOnLastWindowClosed(true)默认就是 true程序退出时 Qt 会清理所有顶层窗口如果你的窗口成员是值语义的MainWindow w;它会被正常析构如果堆上 new 出来的窗口没有 delete退出会直接触发崩溃报告这一现象在 Qt 5.15 上非常常见网上搜qt崩溃能搜到一堆类似的求助。我自己的排查习惯是写完界面后把窗口关掉看 Qt Creator 的 Application Output 有没有 QWidget::setLayout: Attempting to set QLayout on MainWindow , which already has a layout 这类警告。这类警告不是崩溃但往往是布局重叠、按钮丢失的信号。处理干净再继续往下写。6. 验收与答辩测试用例、加分项和一个我常用的验证思路交作业之前我会固定跑一遍这几组手工测试。第 1 组发牌后三家手牌总数分别是 17、17、20且 54 张不重不漏。第 2 组牌型判定输入3334是三带一334455是连对223344是非法连对因为 2 不能进顺子。第 3 组大小比较顺子45678能压34567但不能压JQKA2后者本身非法。第 4 组炸弹逻辑普通对子不能压炸弹炸弹能压三带二王炸压一切。第 5 组AI 在自己手牌只剩 1 张时有牌就冲不出的时候会果断过。答辩时有三个加分点我建议提前准备。第一个是能讲清楚 Card 的 point 编码为什么从 3 编到 17这体现你对数据结构的思考。第二个是能现场改一个参数演示效果比如把 AI 的保留炸弹策略加到对方剩 2 张时也出改完跑一局说明变化。第三个是能说你踩过的坑比如 MSVC 的 UTF-8 乱码、信号槽用函数指针语法规避运行时错误——老师通常更信跳过坑的人而不是没踩过坑的人。最后说一个我自己的验证习惯不管项目做到哪一步每隔半小时单独编译运行一次。期末周很多同学喜欢一口气写 500 行再编译结果几百个报错连在一起根本定位不了。小步提交看起来慢实际是把调试时间摊开了。我用这套方式做完自己的 Qt 斗地主时规则代码大概 300 行界面代码 200 行人机策略 50 行一个晚上从骨架到能玩通第二天再花半天补测试用例和答辩稿。这个节奏比先写 50 个函数再连界面顺畅得多希望帮到你。本文还有配套的精品资源点击获取