☰
Qt+C++实现德州扑克:牌型评估引擎与对局状态机全解析
2026/10/7 9:08:27 网站建设 项目流程

简介:基于QT与C++实现的德州扑克游戏完整项目,面向计算机、通信、人工智能等专业的学生与开发者,尤其适合作为毕业设计或课程设计的参考案例。项目整体为个人毕设成果,代码经过调试测试,可直接运行体验。压缩包共97个文件,涵盖9个C++源文件、8个头文件、Qt界面文件、大量PNG/JPG图片素材及项目配置文件,包体约9.62MB,结构清晰便于查阅。目前已吸引138人关注学习。资源内包含完整的游戏逻辑实现、AI对手策略、界面交互设计及详细文档,可帮助读者理解德州扑克规则在代码中的落地方式,掌握Qt事件处理、绘图与资源管理技巧,并具备进一步二次开发的基础。无论用于学习Qt开发、C++项目实践,还是借鉴毕设答辩思路,都有较高的参考价值。

1. 德州扑克毕设难在哪:界面只是壳,牌型判定和轮次状态机才是正文

把“基于QT+C++开发的德州扑克游戏”放进毕业设计选题里,乍看像是个花哨的界面项目:画一张桌子,把 52 张牌摆上去,按钮一按就能玩。真动手会发现,画界面反而是最轻松的部分,卡住你的通常是两个硬骨头——7 张牌里选出最大的 5 张并正确判级,以及一圈下注里“轮次怎么转、加注怎么结算”。用 Qt+C++ 做这个题目,恰好把 C++ 的面向对象、STL 容器和 Qt 的信号槽、绘图机制全串起来了,工作量可控、可演示性也强。这篇笔记按我习惯的先后顺序来:先写牌型引擎,再画界面,再补文档,最后把编译和运行阶段最容易翻车的坑一条条说清楚。

2. 三层架构与牌型评估引擎:先跑通最难的逻辑

2.1 模块边界:为什么把逻辑层和界面层分开

很多人写 Qt 小游戏,习惯把所有东西塞进 QWidget 子类里:点按钮发牌,直接在槽函数里洗牌、判断胜负、刷新界面。两三百行确实能跑,但等到要加“详细文档说明”时就傻眼了——类图画不出来,因为逻辑和界面缠在一起,评审老师一追问“你这个牌型判断放哪了”就只能翻代码。

我一般会把项目拆成三层:纯 C++ 的逻辑层、Qt 的管理层、QWidget 表现层。逻辑层不 include 任何 Qt 头文件,只有 Card、Deck、Evaluator、GameManager 这些类,用标准容器和算法实现;管理层用 QObject 派生类做桥,把逻辑层事件转成 Qt 信号;表现层只负责绘制和接收点击。这样做的直接好处是:牌型判断这种核心函数可以在命令行下单独跑测试,不用把整个 GUI 拉起来调,后面接文档和答辩都轻松。

边界定了之后,第一步不是建 Qt 工程,而是先写一个纯控制台程序验证规则。德州扑克的规则复杂度远超预期,尤其牌型比较的边界,盲写在界面上会调试到怀疑人生。先把它在 main() 里跑通,再谈画牌桌。

2.2 牌型评估:从 7 张牌里选出最大的 5 张,C++ 实现

德州扑克每人 2 张底牌加 5 张公共牌,最终比的是 7 张里能组成的最大 5 张牌型。常见的新手做法是把 7 张排序后直接看前 5 张,这会在 A2345 这种特殊顺子和花色分布上翻车。最稳的做法是枚举 7 张里所有 5 张组合,一共 C(7,5)=21 种,逐个判级取最大。21 次评估在桌面游戏里性能完全够,代码也最不容易漏。

先定义牌的数据结构:

// evaluator.h #ifndef EVALUATOR_H #define EVALUATOR_H #include <vector> #include <string> enum class Suit { Spade, Heart, Club, Diamond }; enum class Rank { Two = 2, Three, Four, Five, Six, Seven, Eight, Nine, Ten, Jack, Queen, King, Ace }; struct Card { Suit suit; Rank rank; }; using Cards = std::vector<Card>; enum class HandGrade { HighCard, OnePair, TwoPair, ThreeOfAKind, Straight, Flush, FullHouse, FourOfAKind, StraightFlush, RoyalFlush }; // 从 7 张牌里选出最大牌型,tieBreaker 返回主比较键 HandGrade evaluateBest(const Cards& seven, Rank& tieBreaker); #endif

Rank 用枚举类而不是字符串,比较大小直接转 int,Ace 设为 14 方便顺子判断。现在实现判级逻辑。我习惯用“排序后分组计数”的思路,而不是位运算查表——位运算快但答辩时不好讲,分组计数直观且易扩展。

// evaluator.cpp #include "evaluator.h" #include <algorithm> #include <map> static Cards sortedAsc(const Cards& cards) { Cards c = cards; std::sort(c.begin(), c.end(), [](const Card& a, const Card& b) { return static_cast<int>(a.rank) < static_cast<int>(b.rank); }); return c; } static bool isFlush(const Cards& c) { for (size_t i = 1; i < c.size(); ++i) if (c[i].suit != c[0].suit) return false; return true; } static bool isStraightAsc(const Cards& c) { if (c.size() != 5) return false; // A2345 是最小的顺子,单独处理 if (c[0].rank == Rank::Two && c[1].rank == Rank::Three && c[2].rank == Rank::Four && c[3].rank == Rank::Five && c[4].rank == Rank::Ace) return true; for (int i = 1; i < 5; ++i) if (static_cast<int>(c[i].rank) != static_cast<int>(c[i - 1].rank) + 1) return false; return true; } static int popcount(unsigned int x) { int cnt = 0; while (x) { cnt += x & 1; x >>= 1; } return cnt; } static HandGrade evaluateFive(const Cards& five, Rank& tieBreaker) { Cards c = sortedAsc(five); bool flush = isFlush(c); bool straight = isStraightAsc(c); if (flush && straight) { // A2345 的最高牌是 A 但实际最小,直接用最高牌定位会错 tieBreaker = (c[0].rank == Rank::Two && c[4].rank == Rank::Ace) ? Rank::Two : c[4].rank; return (c[0].rank == Rank::Ten && c[4].rank == Rank::Ace) ? HandGrade::RoyalFlush : HandGrade::StraightFlush; } // 按 rank 计数,再按出现次数降序、点数降序排 std::map<Rank, int> count; for (const Card& card : c) count[card.rank]++; std::vector<std::pair<int, Rank>> groups; for (auto& kv : count) groups.push_back({kv.second, kv.first}); std::sort(groups.begin(), groups.end(), [](const std::pair<int, Rank>& a, const std::pair<int, Rank>& b) { if (a.first != b.first) return a.first > b.first; return static_cast<int>(a.second) > static_cast<int>(b.second); }); int top = groups[0].first; int second = groups[1].first; tieBreaker = groups[0].second; // 注意判级顺序:四条和葫芦大于同花和顺子 if (top == 4) return HandGrade::FourOfAKind; if (top == 3 && second == 2) return HandGrade::FullHouse; if (flush) return HandGrade::Flush; if (straight) return HandGrade::Straight; if (top == 3) return HandGrade::ThreeOfAKind; if (top == 2 && second == 2) return HandGrade::TwoPair; if (top == 2) return HandGrade::OnePair; return HandGrade::HighCard; } HandGrade evaluateBest(const Cards& seven, Rank& tieBreaker) { HandGrade best = HandGrade::HighCard; tieBreaker = seven[0].rank; for (unsigned int mask = 0; mask < (1u << 7); ++mask) { if (popcount(mask) != 5) continue; Cards picked; for (int i = 0; i < 7; ++i) if (mask & (1u << i)) picked.push_back(seven[i]); Rank tb; HandGrade g = evaluateFive(picked, tb); if (static_cast<int>(g) > static_cast<int>(best)) { best = g; tieBreaker = tb; } } return best; }

这个实现有几个必须说的点。第一,顺子的 tieBreaker 不能用最高牌,A2345 的最高牌是 A 却是最小顺子,所以返回最低牌作为比较键。第二,同花和顺子的判级顺序排在四条、葫芦之后,因为四条和葫芦不可能同时构成同花,这个顺序不能乱。第三,这里为了篇幅只返回了一个主比较键,真实项目里两对和一对还需要比剩余单牌(kicker),建议把 groups 里前几项的 rank 序列直接存进一个std::vector<Rank>,比较时逐位字典序,这部分在答辩里很加分。

2.3 洗牌与随机源:别让每局都一样

洗牌用 Fisher–Yates 算法,STL 里对应的就是std::shuffle。坑在随机源:很多人图省事用qrand()或者rand(),不播种的话每次启动程序发出来的牌完全一样,演示时连续两局同牌非常尴尬。C++11 以后的正确姿势是:

#include <random> void shuffleDeck(Cards& deck) { std::random_device rd; std::mt19937 gen(rd()); std::shuffle(deck.begin(), deck.end(), gen); }

std::random_device负责取真随机种子,std::mt19937负责后续伪随机序列,质量对游戏足够。要注意random_device在部分 Windows 环境下可能退化成伪随机,追求稳妥可以再混入当前时间戳做种子。牌桌初始化时建 52 张牌,Suit 四个花色各 13 张,写两层循环生成即可。

3. 用 Qt 把牌桌画出来:QPainter 绘图、信号槽交互与下注轮转

3.1 drawCard:一张扑克牌的绘制

界面层我用 Qt Widgets 加 QPainter 手绘,不用切图片资源。这样整个项目就两张图都不用找,源码目录干净,图片版权问题也绕开了。绘制一张牌的思路是:圆角矩形当底,左上角画点数,中间画花色符号。花色用 Unicode 字符,红心、方块算红牌,黑桃、梅花算黑牌。

// TableWidget.cpp 片段 void TableWidget::drawCard(QPainter& p, const Card& card, const QRectF& rc) { p.save(); p.setRenderHint(QPainter::Antialiasing); p.setBrush(QColor("#fdfaf6")); p.setPen(QPen(QColor("#7f8c8d"), 1)); p.drawRoundedRect(rc, 10, 10); bool red = (card.suit == Suit::Heart || card.suit == Suit::Diamond); p.setPen(red ? QColor("#c0392b") : QColor("#2c3e50")); QFont f = p.font(); f.setPointSize(10); f.setBold(true); p.setFont(f); p.drawText(rc.adjusted(6, 4, -6, -4), Qt::AlignLeft | Qt::AlignTop, rankToString(card.rank)); f.setPointSize(18); p.setFont(f); p.drawText(rc, Qt::AlignCenter, suitToString(card.suit)); p.restore(); }

这里有两个实用细节。rc.adjusted(6, 4, -6, -4)是在矩形四周留白,避免文字贴边;花色符号字体选错了会显示成方块,Windows 下中文字体里通常没有完整的扑克花色字形,我在工程里把这些字符用QString::fromUtf8("\u2660")方式构造,并把字体设置成 "Segoe UI Symbol",基本能稳定显示。整个牌桌背景用QPainter::fillRect(rect(), QColor("#35654d"))刷墨绿色,会很像实体牌桌。

3.2 界面按钮与信号槽:玩家动作如何驱动逻辑层

界面底部放一排按钮:弃牌、跟注、加注、新一局。按钮点击通过 Qt 信号槽转到 TableWidget 的槽函数,槽函数里再调逻辑层 GameManager 的方法。关键点:槽函数只做“转发”,不做业务判断。

// TableWidget.cpp 片段 connect(foldBtn_, &QPushButton::clicked, this, &TableWidget::onFold); connect(callBtn_, &QPushButton::clicked, this, &TableWidget::onCall); connect(raiseBtn_, &QPushButton::clicked, this, &TableWidget::onRaise); connect(newRoundBtn_, &QPushButton::clicked, this, &TableWidget::onNewRound); void TableWidget::onCall() { game_.playerAction(Action::Call, 0); update(); // 触发 paintEvent 重绘 } void TableWidget::onRaise() { int bet = raiseSpin_->value(); game_.playerAction(Action::Raise, bet); update(); }

game_是 GameManager 的实例,playerAction返回后游戏状态已经更新,界面只负责调用update()触发重绘。这里要强调一个容易养成但很糟糕的习惯:不要在槽函数里写一长串“如果当前状态是翻牌前并且对方加注了……”这种逻辑。把状态判断全部下沉到 GameManager,界面层永远是无脑转发,后面加 AI 对手时你会感谢这个决定。

3.3 下注轮转状态机:翻牌前到摊牌的完整流程

对局流程本质是一个状态机,状态就是五个阶段。我维护一个GameManager::Stage枚举,每次动作后检查是否满足进入下一阶段的条件。

阶段公共牌数量进入条件本阶段动作
PreFlop 翻牌前0新一局开始发底牌,庄家下盲注,从小盲开始下注
Flop 翻牌3翻牌前所有未弃牌玩家跟注或全下亮 3 张公共牌,从庄家左侧活玩家开始下注
Turn 转牌4翻牌轮结束亮第 4 张公共牌,继续一轮下注
River 河牌5转牌轮结束亮第 5 张公共牌,最后一轮下注
Showdown 摊牌5河牌轮结束剩余玩家比牌,赢家拿走底池

轮内的核心逻辑是“当前玩家索引”和“当前最高下注额”。每个玩家在自己的回合可以弃牌、跟注到当前注、加注。只要有玩家加注,后面的活玩家都要重新跟注,所以循环结束条件不是“每个人都操作过”,而是“所有人都跟注或弃牌”。新手最容易在这里写死循环——我见过一个学弟的代码,判断条件写成“剩余玩家数大于 1 就继续”,结果加注的一方永远等不到轮次结束。正确的终止条件应当是:遍历一圈后,剩余玩家都已跟注当前最高注,或者只剩一个玩家。

4. 源码之外的交付物:6 个文档怎么组织才扛得住答辩

4.1 需求分析和用例:把“能玩”写成可验收的条目

详细文档说明是题目的另一半,很多毕设挂在第二遍答辩就是因为代码能跑但文档像操作手册。需求分析这章要回答“系统要做什么”,别写空话。我见过比较有效的写法是列表 + 用例表格:系統能洗牌并发牌、能正确识别 10 种牌型、支持 2 到 6 人对局、支持弃牌/跟注/加注/全下、模拟筹码结算、计分板展示。每个条目后面写验收标准,比如“随机发牌重复率低于 1%”“连续运行 1000 次牌型判级无错误”。答辩时老师最爱问的就是“你这个需求怎么验证”,提前把验收标准写死,等于把送分题握在手里。

4.2 概要设计与类图:评审老师最常翻的一页

概要设计至少要包含一张模块图、一张类图、一张状态图。模块图把逻辑层、管理层、表现层画成三个方块,标清依赖方向;类图列出核心类和它们的方法,不需要画全,但 Evaluator、GameManager、TableWidget 这三个必须出现。状态图画对局轮回,也就是上面那张表格的图形化版本。

类图我建议手工梳理而不是用工具一键生成,因为工具生成的类图满是 getter/setter,老师看了觉得你没有设计。手工画的时候把关键方法标上去:evaluateBest接收 7 张牌返回牌型,playerAction接收动作和下注额,paintEvent负责全量绘制。这些方法名本身就是设计意图,比画几十个箭头有用。

4.3 测试报告:牌型边界和不合理动作

测试报告是文档里最容易被忽略但最出效果的一节。除了常规的“能打开、能点按钮”之外,要把牌型判级的边界用例列成表格,比如 A2345 是最小顺子、皇家同花顺必须同花 A 到 10、四条和同花同时不可能出现。还有一类用例是“非法操作”,比如筹码不足时点加注、弃牌后继续点跟注、河牌结束后再点加注——系统要能拒绝而不是崩溃。

文档的文件组织我推荐 README.md 管总,docs 目录下按需求、设计、测试拆开。答辩用的 PPT 直接从文档里抽图,不用另画。整个文档写下来 20 到 30 页足够,关键是图和表比文字抢眼,评审老师翻起来省力,提问时也就围绕着你能讲清楚的点走。

5. Qt+C++ 德州扑克五大踩坑记录:从编译失败到运行崩溃

5.1 Qt Kit 路径报错:编译器找不到 msvc2019_64 的头文件

现象:新建 Qt Widgets 工程后一编译就报:-1: error: dependent '..\..\..\..\qt\5.15.2\msvc2019_64\include\QtWidgets' ...,后面跟着一长串 include 找不到,整个项目直接红。

原因:Qt Creator 里 Kit 的 Qt 版本路径配置错了,或者你装的是 MinGW 版 Qt,却在 Kit 里选了 MSVC 编译器。这个报错本质是 qmake 没有找到匹配当前编译器的 include 目录。Windows 下最容易装混:下了 5.15.2 MinGW 包,又用 VS2022 的编译器去构建。

解决:先在 工具→选项→Kits→Qt Versions 里确认 qmake 路径指向的是msvc2019_64/bin/qmake.exe,再确认编译器选的是 Microsoft Visual C++ 2019 64-bit。两者位数和版本必须对应。如果用的是 VS2022,可以装 VS2022 的 Qt 扩展直接生成工程,或者回 Qt Creator 重新选 Kit,不要手动去改代码里的 include 路径——那是治标不治本。

5.2 中文字符串和花色符号乱码

现象:按钮上的“跟注”“加注”显示成乱码,牌面的红心显示成空心方块。

原因:MSVC 编译器的源码字符集处理默认按本地代码页,而 Qt Creator 的编辑器按 UTF-8 保存源文件,两边不一致就会乱。花色字符是 Unicode 的 0x2660 到 0x2666,某些字体里没有对应字形。

解决:源文件统一保存为 UTF-8 带 BOM,这是 MSVC 下最省事的方式;字符串统一用QStringLiteral("跟注")而不是QString("跟注")。花色字符用QString::fromUtf8("\u2660")构造,绘制时把字体设置成 "Segoe UI Symbol"。我见过有人用图片代替字符,可行,但会多出一堆资源文件,能少碰就少碰。

5.3 牌桌绘制闪烁:paintEvent 里直接画太重

现象:每点一次跟注,整个窗口白闪一下,密集操作时视觉上非常难受。

原因:paintEvent 里每次全量绘制整个背景和所有牌,Qt 自动双缓冲在这种情况下仍会闪。这类问题在 Qt 里属于常客,尤其当你画出圆角矩形、渐变、文字混排这些组合时。

解决:把不变化的桌面背景预先画到一张 QPixmap,paintEvent 里先drawPixmap再画变化的牌面。公共牌翻出后才需要更新牌面,底牌在整局内不动,可以单独缓存。我一般把“桌子+座位+按钮区域”和“每张牌”分别离屏渲染,重绘时只拼装,实测闪烁会彻底消失。

5.4 界面卡死:把 AI 决策塞进了 GUI 线程

现象:点击加注后窗口失去响应几秒,拖动都拖不动,看起来像崩溃。

原因:按钮槽函数里如果直接跑 AI 逻辑——比如用蒙特卡洛模拟算胜率,几十万次模拟放在 GUI 线程里,界面当然卡。基础版只做牌型评估是毫秒级的,问题不大,但毕业设计为了亮点往往会加“AI 胜率提示”,一加就露馅。

解决:决策放独立线程。常见做法是std::async+ 信号回传,槽函数里起异步任务,完成后 emit 一个aiFinished(Cards)信号,界面收到信号再刷新。注意异步线程里不要直接操作任何 QWidget,只允许通过信号回主线程。

// 槽函数里起异步任务 #include <future> void TableWidget::onStartAI() { std::async(std::launch::async, [this]() { Cards result = game_.aiDecide(); // 耗时操作在线程里跑 emit aiFinished(result); }); }

信号aiFinished在主线程连接update(),这样界面只在拿到结果后刷新,整个过程不阻塞。这是用 Qt 写游戏最容易踩的坑之一,因为单机小游戏在调试机上很难复现,要多测几轮才能暴露。

5.5 每局都是同一手牌:随机源没播种

现象:程序重启后发的第一局牌和上次完全一样,洗牌形同虚设。

原因:用了qrand()或rand()但没有播种。Qt5 里qsrand()默认种子是 1,所以每次启动的序列完全一致。

解决:按前面 2.3 节用std::random_device+std::mt19937+std::shuffle,一劳永逸。这里补一句,有人在 Windows 下发现random_device每次返回的值都一样,那是因为某些实现用的不是真硬件熵源,补救方法是在种子后面再异或一个QDateTime::currentMSecsSinceEpoch()。

6. 评估函数回归测试与 AI 难度手感:最后值得打磨的两处

牌型评估属于“逻辑错了不报错”的模块,它只会静默地判错牌型,让玩家莫名其妙输掉。所以我在写完评估器后,一定会做一张牌型回归测试表,把经典边界组合都覆盖一遍。项目里单独建一个 console_test.cpp,不链接 Qt,用纯 C++ 跑断言,这样 CI 或者答辩现场都能一键验证评估器正确性。

测试场景手牌 + 公共牌期望牌型
皇家同花顺底牌 A♠ K♠,公共牌 Q♠ J♠ 10♠ 2♥ 3♦RoyalFlush
最小顺子底牌 A♦ 2♣,公共牌 3♠ 4♥ 5♣ 9♠ K♦Straight,主键为 2
同花压顺子底牌 2♠ 5♠,公共牌 9♠ J♠ 3♥ 7♣ K♦Flush
葫芦压同花底牌 9♥ 9♣,公共牌 9♦ 5♠ 5♦ 2♣ A♥FullHouse
两对比单对底牌 K♣ K♦,公共牌 Q♥ Q♠ 2♠ 8♣ A♣TwoPair,主键为 K

这些用例的断言写法很简单:构造 Cards,调用evaluateBest,比较 HandGrade 枚举值和 tieBreaker,不相等就打印失败信息。把表里几十个用例全部跑绿,再接 GUI,之后改代码时心里才有底。这也是我自己写牌类小游戏养成的一个习惯:先证明规则引擎是对的,再动手画界面,否则界面调半天,最后发现输赢判断是错的,那种感觉远比写代码本身痛苦。

AI 难度是另一个能拉开差距的点。基础版 AI 可以只做“手牌强度+随机”的决策,用评估结果映射到动作概率即可。想让 AI 更聪明,把评估值转成胜率估计特征——比如手牌等级、公共牌等级、剩余玩家数,然后给动作一个加权概率。一个简单的激进系数aggression(0.3 保守,0.7 激进)用来调节加注倾向:手牌越强,加注概率越高;系数越高,同样强度下越倾向加注。这个参数留成界面里可调的,答辩时可以现场演示从“龟缩型”到“赌徒型”的差异,比背概念有用得多。调试时我发现一个反直觉的现象:纯随机 AI 偶尔会打得像个高手,因为它会“运气好”地全下赢回来,导致你评估策略改进时误判。所以评测 AI 强弱不能看单局,要跑 1000 局统计平均胜率,这一点务必记牢。

临近提交时,我一般还会做最后一遍“干净环境验证”:换一台没装过 Qt 的机器,照着 README 从 qt 官网下载对应编译器版本的安装包,配置 Qt Creator,跑通整个编译。这个动作能筛掉绝大多数环境类问题,也更贴合毕业设计答辩时的演示需求。以上这些坑我基本都踩过一遍,写出来是希望你走得更顺一些,希望帮到你。

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

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

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

立即咨询