C++ Qt开发斗地主游戏:从发牌到AI出牌的完整实战指南
简介这份C期末大作业提供了一款基于Qt框架的单机版斗地主小游戏完整工程面向需要完成课程设计或希望通过具体项目巩固C与GUI开发能力的在校学生尤其适合期末阶段快速梳理项目思路。项目完整实现了斗地主的核心规则与交互流程包含洗牌、发牌、出牌合法性判断、记分面板与胜负结算等模块并设计了电脑AI用于模拟真实对手出牌能够帮助读者理解游戏逻辑设计、事件驱动编程、Qt控件布局以及基础策略算法。压缩包共119个文件包括12个cpp源文件、11个h头文件、3个ui界面文件以及pro/qrc工程配置与ico图标90张png图片则承担卡牌和界面素材资源使整体压缩包达到5.76MB目录结构清晰便于按功能模块阅读和二次修改。目前该资源已有98人学习覆盖从工程搭建、AI策略到界面渲染的完整流程无论作为期末作业参考还是Qt游戏开发入门练手都具有较高的实用价值。1. 这个期末项目难点不在 C 而在规则翻译学期末的 C 期末大作业清单里Qt实现单机版斗地主小游戏一定是出现频率最高的那个。多数人拿到题的第一反应是做界面画三个玩家、画手牌、点按钮出牌。但真正让这个项目卡壳的从来不是 Qt 控件而是把斗地主那套顺子、连对、飞机、三带一的规则翻译成代码。2025 年了还能看到不少同学交上来的程序界面像模像样一玩就露馅三带一压不过人家、顺子把 2 算进去、AI 永远不出牌。这篇笔记把工程搭建、发牌、出牌判定、单机 AI 四条线拆开讲参数、坑和验证方式都放在对应章节里。适合正在做这个期末作业的人也适合想拿 Qt 练手但不想从零踩一遍坑的开发者。2. Qt 5.15.2 MSVC 的工程搭建从空窗口到三方布局2.1 版本与工具链为什么我选 5.15.2 而不是 6.x先解决环境问题。这个项目我一般固定在 Qt 5.15.2 LTS Qt Creator MSVC2019_64 这套组合上。理由很土但很实际5.15.2 是最后一代大量教材和网上案例覆盖的 LTS 版本你搜 qt 5.15.2 出现的问题基本能直接照抄答案Qt 6 的 API 变化不大但坑是新踩的期末周没时间当小白鼠。编译器侧安装时选 MSVC2019_64 还是 MinGW 是个分岔口MSVC 编出来的 exe 给老师演示时目标机器需要有 Visual C Redistributable 运行库这是个隐藏的翻车点MinGW 不依赖 VC 运行库但速度略慢。常见做法是两个都装写作业用 MSVC最后打包演示再切 MinGW 重新编一份成本不高。有人会用 Qt QML 写这种项目QML 做动效确实爽但斗地主这种强逻辑、控件交互密集的场景Qt Widgets 更直接断点、布局、控件绑定都比 QML 顺手。提示如果你更习惯 Visual Studio装一个 Qt VS Tools 插件也能接着写。但期末建议直接用 Qt Creator少一个插件配置就少一个变量。安装包从官网或清华镜像下载都行组件勾选 MSVC2019 64-bit 和 MinGW 64-bit 两个编译器套件其他 Tools 能省则省。这里有个容易栽的坑Qt Creator 里装了多个 Kit 后工具链选错会直接报:-1: error: dependent ..\..\qt\5.15.2\msvc2019_64\include\qt...翻译过来就是编译器和你选的 Qt 套件不匹配。去工具 - 选项 - Kits里把编译器和 Qt 版本对应起来别选了个 MSVC Kit 却挂 MinGW 编译器。2.2 最小工程pro 文件和 main.cpp 的先跑通很多人上来就拖控件结果自动生成的 MainWindow 连编译都过不了。我一般先把骨架立住一个空窗口能跑起来再往上加牌。项目里最关键的一份文件是 DouDiZhu.proQT core gui widgets TARGET DouDiZhu TEMPLATE app CONFIG c17 SOURCES \ main.cpp \ MainWindow.cpp HEADERS \ MainWindow.h三个参数讲清楚QT widgets在 Qt 5 里必须写它把 Widgets 模块链接进来漏了会报一大串 QWidget 找不到TARGET决定生成的 exe 文件名比如 DouDiZhu.exeCONFIG c17让 MSVC 按 C17 标准编译后面用std::optional、结构化绑定都不用再改。这个项目 STL 用得很重vector 存手牌、array 做计数c17这个配置能让 STL 的新特性全开不用回头改标准。然后是 main.cpp#include QApplication #include MainWindow.h int main(int argc, char *argv[]) { QApplication app(argc, argv); MainWindow w; w.show(); return app.exec(); }逻辑说明QApplication负责整个 Qt 程序的事件循环所有鼠标点击、定时器、刷新都靠它分发w.show()这行不能省不写窗口创建了也不显示这是新手最容易漏的行app.exec()一旦进入程序就停在这里等事件直到窗口关闭才返回。参数层面argc/argv 是 Qt 框架要求的入口参数不要改名字返回 int 给系统0 表示正常退出。到这一步F5 能跑出一个能拖拽、能关闭的空窗口工程才算活了。2.3 三方布局用 Layout 把斗地主桌面撑起来界面我不建议用 Qt Designer 拖直接代码写 Layout。原因很简单斗地主的界面是固定的一上两下布局比重stretch比设计器拖出来的绝对坐标稳得多。在 MainWindow 构造函数里我一般这样立结构MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) { auto *central new QWidget(this); auto *rootV new QVBoxLayout(central); auto *topH new QHBoxLayout(); // 上方两个 AI 玩家 QLabel* aiLeft new QLabel(QStringLiteral(电脑玩家1), central); QLabel* aiRight new QLabel(QStringLiteral(电脑玩家2), central); topH-addWidget(aiLeft); topH-addStretch(1); // 中间留空给牌桌 topH-addWidget(aiRight); rootV-addLayout(topH, 1); playArea new QLabel(QStringLiteral(出牌区), central); playArea-setAlignment(Qt::AlignCenter); playArea-setMinimumHeight(120); // 至少能放下出牌的高度 rootV-addWidget(playArea, 2); handLayout new QHBoxLayout(); // 玩家手牌后续往里放牌按钮 rootV-addLayout(handLayout, 3); setCentralWidget(central); }参数说明addLayout的第二个参数是拉伸因子AI 区、出牌区、手牌区按 1:2:3 分配纵向空间窗口拉大时比例不变这就是 Layout 比绝对坐标省心的地方setMinimumHeight(120)让出牌区被压扁时也有最小可读高度handLayout单独存一份在成员变量里后面发牌时往里 addWidget 按钮就行。做完这一步界面上应该出现两个 AI 名称、一个出牌区、一个空手牌区。界面设计到这一步就够了再精致的按钮皮肤放到功能跑通以后再说期末时间不该花在抠像素上。3. 牌的编码与发牌逻辑把 54 张牌装进 vector3.1 用 int 编码牌比结构体更省心常见错误是一上来就定义struct Card { int suit; int rank; QString name; };然后发现半个程序都在构造、比较和拷贝 Card。我一般用最朴素的方案牌就是一个 int0 到 53 共 54 个数编码规则固定成一张表。卡牌对象编码范围说明方块 3 到 20 ~ 12rank 卡牌号 % 13梅花 3 到 213 ~ 25花色 卡牌号 / 13红桃 3 到 226 ~ 38同上黑桃 3 到 239 ~ 51同上小王 / 大王52 / 53单独分配这样牌的数据结构就退化到两个字段点数 rank 和花色 suit。拿点数直接取模// 返回牌的“点数档位”03, 14, ..., 11A, 122, 13小王, 14大王 int rankOf(int card) { if (card 52) return 13; if (card 53) return 14; return card % 13; // 编码时 rank 放在低位 }参数说明card % 13能拿到点数依赖花色占高位、点数占低位的编码约定编码时别把顺序搞反13 和 14 两个档位专门给大小王是为了后面判断顺子、连对时能把王直接排除。显示用的牌名我习惯用一个 const char* 数组初值直接给const char* rankNames[] {3, 4, 5, 6, 7, 8, 9, 10, J, Q, K, A, 2, 小王, 大王};这样rankOf返回的档位直接当下标取名字不用再写 if 分支。注意这个数组一共 15 个元素档位 14 对应最后一个大王。字符串数组初始化这里有个小讲究用const char*还是std::string都行但const char*数组在 Qt 里转 QString 时记得包QString::fromUtf8或直接走QStringLiteral不然中文名在 MSVC 下又是乱码这个问题第 5 章会专门讲。3.2 Fisher-Yates 洗牌别用 rand发牌的第一步是洗牌。C 语言的 rand() 是线性同余发生器洗 54 张牌足够产生肉眼可见的规律连着几局总会出相似的牌序。我一般直接上 std::mt19937配合 Fisher-Yates 从后往前交换#include numeric #include random #include algorithm #include chrono std::vectorint deck(54); std::iota(deck.begin(), deck.end(), 0); // 填入 0..53 std::mt19937 rng(std::chrono::steady_clock::now() .time_since_epoch().count()); for (int i 53; i 0; --i) { int j rng() % (i 1); // 注意是 i1 不是 i std::swap(deck[i], deck[j]); }两个点要讲透一是 rng 的种子用系统时钟不然每次启动程序都是同一副牌二是 j 的取值范围是 [0, i] 而不是 [0, i-1]当前下标 i 自己也要参与交换这是 Fisher-Yates 的正确形式。很多网络代码在 j 的边界上写错洗出的牌虽然看起来乱但统计上有偏。std::iota是 numeric 头文件里的填充函数一次把 0 到 53 填进去比手写 for 循环干净。3.3 发牌、底牌与叫地主54 张牌三个人轮着发每人 17 张剩 3 张底牌逻辑不复杂容易错的是边界std::arraystd::vectorint, 3 hands; for (int i 0; i 51; i) hands[i % 3].push_back(deck[i]); std::vectorint diPai(deck.begin() 51, deck.end());i 从 0 到 50 共 51 张i % 3轮流分发deck 尾部三张直接拷给底牌。这里最容易写错的是把循环写成 i 54那底牌就没了。叫地主我一般做成最简单的轮流叫一个 int 记录当前玩家每个玩家都能叫/不叫第一个叫的人拿底牌。拿到底牌后要把手牌重新排序。我习惯按点数降序排出牌判断时从左往右扫牌面也是大牌在前auto cmpCard [](int a, int b) { int ra rankOf(a), rb rankOf(b); if (ra ! rb) return ra rb; // 点数大的排前面 return a b; // 同点数按编码兜底 }; std::sort(hand.begin(), hand.end(), cmpCard);参数说明这个比较器是先点数后花色大小王档位 14/13 自然排在最前面排序结果直接决定 UI 上牌按钮的排列顺序所以 cmpCard 一定要满足严格弱序别在 lambda 里写否则 sort 会直接崩溃Qt 下经常报 vector subscript out of range。看到这种报错先从比较器排查这是 STL 使用里最常见的崩溃来源。4. 出牌规则判定牌型识别与大小比较的实现4.1 先把牌型枚举和计数数组定下来规则判定是整个项目的地基。我建议把所有牌型定成一个枚举单独放头文件四个字可读可测enum CardType { CT_SINGLE, // 单张 CT_PAIR, // 对子 CT_TRIPLE, // 三张 CT_TRIPLE_ONE, // 三带一 CT_TRIPLE_TWO, // 三带二 CT_STRAIGHT, // 顺子 CT_PAIR_STRAIGHT, // 连对 CT_PLANE, // 飞机不带 CT_PLANE_ONE, // 飞机带单 CT_PLANE_TWO, // 飞机带双 CT_FOUR_TWO, // 四带二 CT_BOMB, // 炸弹 CT_ROCKET, // 火箭 CT_INVALID // 非法出牌 };判断函数第一步把任意一组牌折叠成点数 - 张数的计数数组std::arrayint, 15 toCount(const std::vectorint cards) { std::arrayint, 15 cnt{}; for (int c : cards) cnt[rankOf(c)]; return cnt; }cnt 的下标和 rankOf 的档位完全对齐0 表示没有这种牌之后所有牌型判断都在这 15 个格子上做不用再回到原始牌面。cnt 数组长度为 15 而不是 13就是为了给大小王留档位初始化cnt{}让所有元素归零否则未初始化数组里的随机值是后续一切判断 bug 的根源。4.2 顺子、连对、飞机区间扫描比递归好懂最常见的一个误解是拿递归去枚举所有组合判断顺子期末项目递归深度浅但逻辑像个黑匣子答辩讲不清。我更推荐区间扫描从低位到高位扫 cnt统计连续的非零段int first -1, last -1, len 0; for (int r 0; r 11; r) { // 只扫到 A档位 11 if (cnt[r] 0) { if (first 0) first r; last r; len; } } bool isStraight (len 5) (last - first 1 len);三个坑写死在这里循环上限是 11 不是 12因为 2 不参与顺子必须要求last - first 1 len否则 3、5、7 这种中间断档的牌也会被误判成顺子长度必须大于等于 5斗地主顺子最少五张。连对和飞机的思路完全一样扫描条件从cnt[r] 0换成cnt[r] 2和cnt[r] 3再加长度下限连对三对起、飞机两组起。区间扫描的额外好处是处理带牌方便。比如三带一先找cnt[r] 3的三张再看剩余 1 张与三张是不是同点数。按斗地主规则三带一不能带自己那张比如三张 3 带一张 3那本质上就是四张 3 了应该当炸弹处理。这块放炸弹判定里不会漏。4.3 判定主函数按牌型分路我把所有判断收进一个函数输入一组牌返回类型和关键点数CardType analyze(const std::vectorint cards, int keyRank) { auto cnt toCount(cards); int n (int)cards.size(); // 火箭 if (n 2 cnt[13] 1 cnt[14] 1) { keyRank 14; return CT_ROCKET; } // 炸弹 if (n 4) { for (int r 0; r 14; r) if (cnt[r] 4) { keyRank r; return CT_BOMB; } } // 频次数组 f统计 cnt 里出现 i 次的点数各有几个 std::arrayint, 5 f{}; for (int r 0; r 14; r) f[cnt[r]]; if (n 1) { keyRank 0; while (!cnt[keyRank]) keyRank; return CT_SINGLE; } if (n 2 f[2] 1) { keyRank 0; while (cnt[keyRank] ! 2) keyRank; return CT_PAIR; } if (n 4 f[3] 1 f[1] 1) { keyRank 0; while (cnt[keyRank] ! 3) keyRank; return CT_TRIPLE_ONE; } if (n 5 f[3] 1 f[2] 1) { keyRank 0; while (cnt[keyRank] ! 3) keyRank; return CT_TRIPLE_TWO; } if (n 6 f[4] 1 (f[1] 2 || f[2] 1)) { keyRank 0; while (cnt[keyRank] ! 4) keyRank; return CT_FOUR_TWO; } // 顺子/连对/飞机调用 4.2 的区间扫描逻辑 // ... return CT_INVALID; }f数组是把 15 个点数的出现次数再做一层折叠f[1] 是单张个数f[2] 是对子个数f[3] 是三张个数f[4] 是炸弹个数。有了它组合类牌型变成 O(1) 判断是这题性价比最高的一招。keyRank是用于比大小的关键点数比如三带一取三张的 rank四带二取四张的 rank它只对同类型比较有意义。这里的 while 找 rank 是从小往大找保证多个三张时取最小那个做 key符合斗地主同型比较关键点数的约定。4.4 大小比较函数类型、关键点、炸弹、火箭bool canBeat(const std::vectorint cur, const std::vectorint last) { int curKey 0, lastKey 0; CardType ct analyze(cur, curKey); CardType lt analyze(last, lastKey); if (ct CT_INVALID || lt CT_INVALID) return false; if (ct CT_ROCKET) return lt ! CT_ROCKET; if (lt CT_ROCKET) return false; if (ct CT_BOMB lt ! CT_BOMB) return true; if (ct CT_BOMB lt CT_BOMB) return curKey lastKey; if (ct ! lt) return false; // 不同普通牌型不能互相压 return curKey lastKey; }逻辑链火箭最大炸弹只输给火箭和更大的炸弹普通牌型之间必须同类且关键点大才压得上。比较时千万别拿 vector 整体做字典序比较因为三带一的两组牌点数不同vector 逐元素比较会给出错误答案。这也是为什么 analyze 要把 keyRank 单独抽出来。写到这里规则层已经可以脱离界面独立测试了这是后面所有调试信心来源。4.5 边界为什么不做最优拆牌优化讲完判定很多人会问AI 怎么从一手 17 张里选出要出的组合这属于组合优化问题穷举复杂度接近 2 的 17 次方期末作业不需要最优解。常见做法是贪心AI 先把顺子、连对、飞机这些多张组合摘出来剩下的按单张、对子、三张拆出牌时优先出最小且能压住当前的组合。第 6 章会给具体的贪心选择函数。这里先守住职责边界analyze 和 canBeat 只负责判定不负责找组合。两个职责分离后代码的测试难度和答辩讲解难度都会降一大截。5. Qt/C 实战避坑5 个期末周最常踩的雷这几条不是猜测是我把这个项目从空窗口做到能玩的路上真实遇到过、也看同学反复踩的五个点位。每一条按现象、原因、解决三步写照着排查就行。5.1 exe 拷到别的电脑上双击就崩现象Qt Creator 里编译运行一切正常把 build 目录里的 exe 拷到老师机器或室友电脑上双击直接黑屏退出或弹窗报找不到 Qt5Widgets.dllVCRUNTIME140.dll missing。原因Qt 程序默认动态链接一堆 Qt 的 DLL 和 MSVC 运行时库自己机器上有是因为开发环境装了全套目标机器没有。这属于 Qt 程序打包的标准知识点不是玄学。解决演示前用 windeployqt 把依赖自动拷到 exe 目录cd /d D:\build-DouDiZhu-Desktop_Qt_5_15_2_MSVC2019_64bit-Release set PATHC:\Qt\5.15.2\msvc2019_64\bin;%PATH% windeployqt --release DouDiZhu.exewindeployqt 是 Qt 自带的命令行工具在 Qt 安装目录的 bin 下。它会扫描 exe 的导入表把需要的 Qt 模块 DLL、插件、翻译包全部复制到同目录把整个文件夹拷走就行。如果目标机器还缺 VC 运行库两条路选一装 VC_redist.x64.exe或换 MinGW 套件重新编一份MinGW 不依赖 VC 运行库。期末演示我一般提前用 MinGW 重新编一版省去现场装运行库的尴尬。5.2 界面中文全部变成乱码现象按钮上的出牌过叫地主全变成问号或者汉字错位成奇怪字符。原因MSVC 编译时对源文件里的中文字符串按本地代码页GBK解释源文件却是 UTF-8 存下来的两边对不上字符串字面量在内存里就坏了。解决三个措施任选一个。把源文件统一存成 UTF-8 with BOM或在每个 .cpp 顶部加#if defined(_MSC_VER) #pragma execution_character_set(utf-8) #endif或者所有用户可见字符串一律用QStringLiteral包起来写例如QStringLiteral(出牌)。这三个办法能根治别再往 QString::fromLocal8Bit 上到处打补丁。5.3 clicked 信号连了槽按钮按了没反应现象编译完全通过点出牌按钮界面一点动静都没有。原因最常见是 connect 的信号和槽签名不匹配或者把clicked(int)接到一个无参槽上新版 Qt 对这种写法运行时不报错但不执行有人用旧式 SIGNAL/SLOT 宏把参数写错编译期还查不出来。解决全部改成新式函数指针写法connect(playBtn, QPushButton::clicked, this, MainWindow::onPlayClicked);clicked 是重载信号函数指针写法能明确绑定的是哪个重载。改了还不行就在槽函数第一行加qDebug() onPlayClicked;跑起来点按钮看程序输出有没有打印没打印就是连接断了打印了说明槽在跑问题在槽内部逻辑。这招排查比断点还快。5.4 手牌出着出着就多牌了现象打了几轮玩家手牌数量对不上点过的牌偶尔又出现在手牌区。原因这类问题大多出在函数参数传递上。最常见的是把出牌函数写成void playHand(std::vectorint hand)按值传副本出牌逻辑操作的是副本原手牌 vector 根本没变。解决明确出牌接口的输入输出用引用传递并规定谁能改// 从 fromHand 中移除选中的牌并加入 playedList void playHand(std::vectorint fromHand, const std::vectorint selected, std::vectorint playedList);参数语义fromHand是唯一允许被修改的源手牌selected是 UI 层选中的牌只读playedList是这一轮出的牌用于出牌区显示和 AI 判断。这题就是体感引用、指针、值传递三种写法差别最好的场景。另外移除元素时注意用 remove/erase 组合别在循环里一个个 erase后者会因为迭代器失效直接崩溃也是 STL 使用的经典雷区。5.5 窗口拉伸后手牌按钮挤成一排现象窗口拉大手牌区按钮高度不变或间距爆炸窗口拉小出牌区被挤得只剩一条缝。原因布局没分配拉伸因子或者用setFixedSize把窗口锁死了。解决回到第 2 章那个 rootV 布局给三个区域显式分配拉伸因子并给手牌区套 QScrollArea 兜底auto *scroll new QScrollArea; scroll-setWidgetResizable(true); scroll-setWidget(handContainer); // handContainer 里放 QHBoxLayout rootV-addWidget(scroll, 3);setWidgetResizable(true)配合 Layout 可以达到牌多时出现滚动条牌少时贴合内容。这个参数是 Qt 里少数改一下观感立刻不一样的关键参数值得记。提示把第 4 章的 analyze 和 canBeat 写成一组纯函数先单独编译测试再接 UI。规则层问题和交互层问题混在一起查是期末周最耗时间的做法。6. 单机版 AI 与残局自测让对手像人一样出牌6.1 AI 出牌把能压住的最小的牌打出去单机版的电脑玩家本质就是一圈贪心决策。接到上家出牌后AI 要决定跟牌还是过牌。实现上是一个遍历枚举自己能打出的合法组合选关键点数最小且能压住上家的那一组auto cardOfRank [](int r) { if (r 13) return 52; // 小王 if (r 14) return 53; // 大王 return r; // 花色 0 的牌 }; bool aiFindResponse(const std::vectorint hand, const std::vectorint last, std::vectorint out) { auto cnt toCount(hand); for (int r 0; r 14; r) { if (cnt[r] 1) continue; std::vectorint trial {cardOfRank(r)}; if (canBeat(trial, last)) { out trial; return true; } } // 对子、三带、顺子依次类推循环顺序先小后大 // 炸弹、火箭放到普通牌型都压不住时再尝试 return false; // 压不上过 }这段逻辑里 r 从 0 到 14决定了 AI先出小点、后出大点的观感炸弹和火箭不是普通牌型遍历时要单独放在最后只有普通牌型压不住才动用炸弹避免 AI 开局就把炸弹打光。AI 主动领出时更简单从手牌挑最小的单张或最小的能完整打出的顺子、对子贪心的本质就是先把小牌打出去。这样做的 AI 打不过高手但打期末答辩足够代码量一百行以内。6.2 残局自测不开界面也能验证规则第 4 章的 analyze 和 canBeat 是纯函数最好的验证方式就是构造残局直接跑不用开界面。我习惯在启动时加一段自测代码用 Q_ASSERT 包住演示时不影响正常游戏void selfTest() { Q_ASSERT(analyze({4, 17, 30}) CT_TRIPLE); // 三张7 Q_ASSERT(analyze({0, 1, 2, 3, 4}) CT_STRAIGHT); // 34567 Q_ASSERT(canBeat({10, 23, 36}, {5, 18, 31})); // 三张K压三张8 Q_ASSERT(!canBeat({0, 1, 2, 3, 4}, {0, 1, 2, 3, 5})); // 顺子比尾牌 }残局用例照着规则手写十几组重点覆盖顺子断档、三带一不能带同一张、2 不进顺子、火箭压炸弹这几条边界。跑通后把用例注释留着答辩时老师问你怎么验证正确性直接打开这段给他看。这个位置也是 Qt 项目里少有的、不装任何测试框架就能做回归验证的地方。这个项目我从空窗口到完整打一局前后大概一周。最大的教训就是先做界面、后补规则导致判定函数边界一改界面行为全乱等于黑匣子排错排了三天。后来把 analyze 和 canBeat 拆成纯函数先跑通测试界面只是这些判断的展示层两天就把主要功能收干净了。期末时间紧别指望一次把 AI 做出智能规则扎实、程序不崩、路径清晰就已经是一份能拿高分的 Qt 单机版斗地主了。希望帮到你。本文还有配套的精品资源点击获取