☰
C++ Qt开发斗地主游戏:从发牌到AI出牌的完整实战指南
2026/10/4 6:01:16 网站建设 项目流程

简介:这份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.pro:

QT += core gui widgets TARGET = DouDiZhu TEMPLATE = app CONFIG += c++17 SOURCES += \ main.cpp \ MainWindow.cpp HEADERS += \ MainWindow.h

三个参数讲清楚:QT += widgets在 Qt 5 里必须写,它把 Widgets 模块链接进来,漏了会报一大串 QWidget 找不到;TARGET决定生成的 exe 文件名,比如 DouDiZhu.exe;CONFIG += c++17让 MSVC 按 C++17 标准编译,后面用std::optional、结构化绑定都不用再改。这个项目 STL 用得很重,vector 存手牌、array 做计数,c++17这个配置能让 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 张牌装进 vector

3.1 用 int 编码牌:比结构体更省心

常见错误是一上来就定义struct Card { int suit; int rank; QString name; };,然后发现半个程序都在构造、比较和拷贝 Card。我一般用最朴素的方案:牌就是一个 int,0 到 53 共 54 个数,编码规则固定成一张表。

卡牌对象编码范围说明
方块 3 到 20 ~ 12rank = 卡牌号 % 13
梅花 3 到 213 ~ 25花色 = 卡牌号 / 13
红桃 3 到 226 ~ 38同上
黑桃 3 到 239 ~ 51同上
小王 / 大王52 / 53单独分配

这样牌的数据结构就退化到两个字段:点数 rank 和花色 suit。拿点数直接取模:

// 返回牌的“点数档位”:0=3, 1=4, ..., 11=A, 12=2, 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::vector<int> 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); // 注意是 i+1 不是 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::array<std::vector<int>, 3> hands; for (int i = 0; i < 51; ++i) hands[i % 3].push_back(deck[i]); std::vector<int> 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::array<int, 15> toCount(const std::vector<int>& cards) { std::array<int, 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::vector<int>& 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::array<int, 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::vector<int>& cur, const std::vector<int>& 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.dll""VCRUNTIME140.dll missing"。

原因:Qt 程序默认动态链接一堆 Qt 的 DLL 和 MSVC 运行时库,自己机器上有是因为开发环境装了全套,目标机器没有。这属于 Qt 程序打包的标准知识点,不是玄学。

解决:演示前用 windeployqt 把依赖自动拷到 exe 目录:

cd /d D:\build-DouDiZhu-Desktop_Qt_5_15_2_MSVC2019_64bit-Release set PATH=C:\Qt\5.15.2\msvc2019_64\bin;%PATH% windeployqt --release DouDiZhu.exe

windeployqt 是 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::vector<int> hand),按值传副本,出牌逻辑操作的是副本,原手牌 vector 根本没变。

解决:明确出牌接口的输入输出,用引用传递并规定谁能改:

// 从 fromHand 中移除选中的牌并加入 playedList void playHand(std::vector<int>& fromHand, const std::vector<int>& selected, std::vector<int>& 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::vector<int>& hand, const std::vector<int>& last, std::vector<int>& out) { auto cnt = toCount(hand); for (int r = 0; r <= 14; ++r) { if (cnt[r] < 1) continue; std::vector<int> 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 单机版斗地主了。希望帮到你。

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

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

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

立即咨询