ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Qt+C++实现德州扑克:牌型评估引擎与对局状态机全解析

Qt+C++实现德州扑克:牌型评估引擎与对局状态机全解析 简介基于QT与C实现的德州扑克游戏完整项目面向计算机、通信、人工智能等专业的学生与开发者尤其适合作为毕业设计或课程设计的参考案例。项目整体为个人毕设成果代码经过调试测试可直接运行体验。压缩包共97个文件涵盖9个C源文件、8个头文件、Qt界面文件、大量PNG/JPG图片素材及项目配置文件包体约9.62MB结构清晰便于查阅。目前已吸引138人关注学习。资源内包含完整的游戏逻辑实现、AI对手策略、界面交互设计及详细文档可帮助读者理解德州扑克规则在代码中的落地方式掌握Qt事件处理、绘图与资源管理技巧并具备进一步二次开发的基础。无论用于学习Qt开发、C项目实践还是借鉴毕设答辩思路都有较高的参考价值。1. 德州扑克毕设难在哪界面只是壳牌型判定和轮次状态机才是正文把“基于QTC开发的德州扑克游戏”放进毕业设计选题里乍看像是个花哨的界面项目画一张桌子把 52 张牌摆上去按钮一按就能玩。真动手会发现画界面反而是最轻松的部分卡住你的通常是两个硬骨头——7 张牌里选出最大的 5 张并正确判级以及一圈下注里“轮次怎么转、加注怎么结算”。用 QtC 做这个题目恰好把 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::vectorCard; enum class HandGrade { HighCard, OnePair, TwoPair, ThreeOfAKind, Straight, Flush, FullHouse, FourOfAKind, StraightFlush, RoyalFlush }; // 从 7 张牌里选出最大牌型tieBreaker 返回主比较键 HandGrade evaluateBest(const Cards seven, Rank tieBreaker); #endifRank 用枚举类而不是字符串比较大小直接转 intAce 设为 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_castint(a.rank) static_castint(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_castint(c[i].rank) ! static_castint(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::mapRank, int count; for (const Card card : c) count[card.rank]; std::vectorstd::pairint, Rank groups; for (auto kv : count) groups.push_back({kv.second, kv.first}); std::sort(groups.begin(), groups.end(), [](const std::pairint, Rank a, const std::pairint, Rank b) { if (a.first ! b.first) return a.first b.first; return static_castint(a.second) static_castint(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_castint(g) static_castint(best)) { best g; tieBreaker tb; } } return best; }这个实现有几个必须说的点。第一顺子的 tieBreaker 不能用最高牌A2345 的最高牌是 A 却是最小顺子所以返回最低牌作为比较键。第二同花和顺子的判级顺序排在四条、葫芦之后因为四条和葫芦不可能同时构成同花这个顺序不能乱。第三这里为了篇幅只返回了一个主比较键真实项目里两对和一对还需要比剩余单牌kicker建议把 groups 里前几项的 rank 序列直接存进一个std::vectorRank比较时逐位字典序这部分在答辩里很加分。2.3 洗牌与随机源别让每局都一样洗牌用 Fisher–Yates 算法STL 里对应的就是std::shuffle。坑在随机源很多人图省事用qrand()或者rand()不播种的话每次启动程序发出来的牌完全一样演示时连续两局同牌非常尴尬。C11 以后的正确姿势是#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. QtC 德州扑克五大踩坑记录从编译失败到运行崩溃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 里属于常客尤其当你画出圆角矩形、渐变、文字混排这些组合时。解决把不变化的桌面背景预先画到一张 QPixmappaintEvent 里先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_devicestd::mt19937std::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 更聪明把评估值转成胜率估计特征——比如手牌等级、公共牌等级、剩余玩家数然后给动作一个加权概率。一个简单的激进系数aggression0.3 保守0.7 激进用来调节加注倾向手牌越强加注概率越高系数越高同样强度下越倾向加注。这个参数留成界面里可调的答辩时可以现场演示从“龟缩型”到“赌徒型”的差异比背概念有用得多。调试时我发现一个反直觉的现象纯随机 AI 偶尔会打得像个高手因为它会“运气好”地全下赢回来导致你评估策略改进时误判。所以评测 AI 强弱不能看单局要跑 1000 局统计平均胜率这一点务必记牢。临近提交时我一般还会做最后一遍“干净环境验证”换一台没装过 Qt 的机器照着 README 从 qt 官网下载对应编译器版本的安装包配置 Qt Creator跑通整个编译。这个动作能筛掉绝大多数环境类问题也更贴合毕业设计答辩时的演示需求。以上这些坑我基本都踩过一遍写出来是希望你走得更顺一些希望帮到你。本文还有配套的精品资源点击获取
返回列表