简介:基于QT+C++开发的德州扑克游戏项目完整源码与配套文档,是面向计算机、通信、人工智能、自动化等专业学生及从业者的毕业设计级资料,适合作为期末课程设计、课程大作业或毕设参考。项目经过调试测试,核心机制可正常运行,具备较高的二次开发空间。资源包共97个文件、容量9.62MB,涵盖cpp/h源码、ui界面设计文件、png/jpg图像素材、qrc资源文件、pro工程文件及doc说明文档,目录结构清晰,便于按模块查阅。目前已吸引138人学习浏览。借助这份资料,读者可以完整了解基于QT框架的桌面游戏开发思路,包括游戏主循环、AI决策、牌型判断、界面布局与事件处理等关键模块,同时可结合文档快速梳理项目逻辑,在此基础上修改规则、扩展玩法或重构界面,从而提升C++与QT的实际项目经验。
1. 用Qt+C++做德州扑克毕设:牌型判断只是入门,流程状态机才是大头
很多毕业生选“基于Qt+C++的德州扑克游戏”这个题目,是因为看着界面比管理系统炫、规则又耳熟能详。可真上手写两天就会明白:德州扑克的牌型判断、比牌大小只是最表层的活,70%的工作量落在对局流程控制、玩家操作轮转、底池计算和AI决策上。Qt的Widgets提供了按钮、布局、绘图这套现成的骨架,C++负责算法和对象模型,两者配合做回合制棋牌是正路,但如果你直接用while(1)死循环等玩家点击,窗口会当场白屏卡死。这篇按我实际做过的方案,把类怎么拆、牌型怎么判、回合怎么流转、坑在哪里讲清楚,新手能照着一路写完,熟手看参数和边界也能少走弯路。
2. 德州扑克的工程拆解:先把对局流程画成状态机,再写界面
2.1 为什么先定流程而不是先画界面
德州扑克一局有五个阶段:翻牌前(Preflop)、翻牌(Flop)、转牌(Turn)、河牌(River)、摊牌(Showdown)。每个阶段里,玩家按顺时针轮转做出弃牌、过牌、跟注、加注或全下的动作。规则链很长,最忌讳一边画界面一边想逻辑。先把流程画清楚,界面只是状态机的“显示器”。
我一般会在项目根目录放一个docs/game_flow.md,用文字穷举状态和迁移:某个阶段下能做什么动作、什么时候推进到下一阶段、庄家(Button)和小盲(SB)/大盲(BB)位置怎么轮转。这个文档不花多少时间,但后面写代码时不用反复回去看规则,尤其能避免“玩家加注后,下一位是否还能再加注”这类边界问题。
常见的状态机设计是:用枚举表达游戏阶段,用Table类持有所有玩家、底池、公共牌和当前操作者索引。玩家的动作由Player类发出信号,Table的槽函数负责校验合法性并推进状态。界面层只监听状态变化然后重绘,这样职责是单向的:界面不会直接改游戏数据。
2.2 核心类划分:Table、Player、Deck、HandEvaluator 各管一摊
一个容易失控的教训是:把牌堆、手牌、界面全塞进MainWindow,最后MainWindow变成几千行的上帝类。合理切分是:
Card:单张牌,包含花色和点数,提供toString()显示。Deck:52张牌的容器,负责洗牌和发牌。Player:玩家状态,包括手牌、筹码、当前动作、是否弃牌/全下,发出动作信号。HandEvaluator:静态工具类,输入5~7张牌,输出最优牌型和比较结果。Table:对局核心,持有Deck、玩家列表、底池、公共牌、当前阶段,负责推进回合。MainWindow:只做显示和事件转发。
这里的关键是Player不直接调用Table的方法。玩家点击“跟注”按钮后,Player::actionSelected(Action, amount)信号发出,Table在槽函数里决定这个动作是否合法。反过来,Table也不该直接调Player的界面方法,它只需要发出stageChanged、playerTurnChanged这些信号让窗口刷新。
// player.h 核心信号 class Player : public QObject { Q_OBJECT public: explicit Player(const QString& name, int chips, QObject* parent = nullptr); void resetForHand(); signals: void actionSelected(Action action, int amount); // 玩家在界面上点完按钮后发出 void chipsChanged(int chips); // 筹码变动后发出,界面刷新 };逻辑说明:actionSelected携带动作类型和金额(跟注/加注时的具体数值),Table在槽里统一处理,Player自己不修改底池状态。业务规则集中在一处,排错时只要盯着Table看。
参数说明:Action是枚举{Fold, Check, Call, Raise, AllIn};chips是剩余筹码,归零时玩家进入旁观状态。resetForHand()在每局开始时清空手牌、重置动作状态,但保留筹码值。
2.3 用信号槽驱动回合流转,别用 while 轮询
初学者最容易写出这样的代码:while (!playerActed) { QApplication::processEvents(); }。这在小型Demo里能跑,但会出现两个问题:一是嵌套事件循环导致界面按钮连点、重入;二是每轮都消耗大量CPU,窗口卡顿。
正确做法是“事件驱动”:当前轮转到谁,就只给谁的按钮启用。玩家点完,信号触发Table::onActionSelected,在这个槽里完成底池计算、下一玩家索引推进、阶段切换,然后发出新的playerTurnChanged信号让界面更新。
// table.cpp 里动作处理的简化逻辑 void Table::onActionSelected(Action action, int amount) { Player* p = players_[currentPlayerIdx_].get(); switch (action) { case Action::Fold: p->setFolded(true); break; case Action::Call: { int callChips = currentBet_ - p->currentBet(); p->bet(callChips); pot_ += callChips; break; } case Action::Raise: { int raiseChips = amount - p->currentBet(); p->bet(raiseChips); pot_ += raiseChips; currentBet_ = amount; lastRaiseIdx_ = currentPlayerIdx_; break; } case Action::AllIn: pot_ += p->bet(p->chips()); break; default: // Check break; } advanceToNextActivePlayer(); }逻辑说明:currentBet_表示当前阶段的最大下注额,玩家跟注只需要补足差额;加注则同时更新currentBet_并记录最后加注者,用于判断“一圈没人再加注”时结束当前阶段。
参数说明:pot_是底池累计值;lastRaiseIdx_初始化为-1。当轮转一圈回到该玩家且没有新加注发生,就调用enterNextStage()推进到下一张公共牌,否则继续轮转。
信号槽的连接建议用Qt::AutoConnection就好,所有对象都在GUI线程时它就是直连,效率没问题。如果后续把AI思考放到工作线程,再改成QueuedConnection并通过QThread::postEvent传结果,避免跨线程直接操作界面。
3. 手写牌型判断与洗牌逻辑:能跑的最小C++实现
3.1 牌型枚举与牌值映射:用枚举比字符串判断靠谱得多
牌型判断是德州扑克最容易“看着对、实则错”的部分。七张牌里选五张、还要比较踢脚(Kicker),用字符串拼接比较基本是给自己挖坑。正确做法是定义整数枚举,点数从2到A映射到0到12,花色0到3。
enum Suit { Spade = 0, Heart, Club, Diamond }; enum HandRank { HighCard = 0, OnePair, TwoPair, ThreeOfAKind, Straight, Flush, FullHouse, FourOfAKind, StraightFlush, RoyalFlush }; struct Card { int rank; // 0=2, 1=3, ..., 12=A int suit; // 0=Spade, 1=Heart, 2=Club, 3=Diamond Card(int r = 0, int s = 0) : rank(r), suit(s) {} QString toString() const { static const char* ranks = "23456789TJQKA"; return QString("%1%2").arg(ranks[rank]) .arg(QString("SHDC")[suit]); } };逻辑说明:rank0到12的顺序必须固定,后面比牌大小直接比较std::vector<Card>的字典序。Rank不要直接存字符,否则'10'和'J'不好比较大小。用整数和查找表是省事且不容易错的做法。
参数说明:toString()里T表示10,这是扑克牌的通用记法。输出如AS(黑桃A)、KD(方块K),界面显示和日志输出都能直接复用。
3.2 从7张牌里挑出最好的5张:暴力枚举最稳
网上能看到各种“精巧”的牌型判断算法,但做毕业设计最该选的是正确性好、可维护的方案:枚举7张牌的所有5张组合,逐个评分取最大。组合数C(7,5)=21,算一次毫秒级都不到,完全没必要用位运算黑魔法。
std::vector<Card> bestHand(std::vector<Card> cards) { std::sort(cards.begin(), cards.end(), [](const Card& a, const Card& b) { return a.rank > b.rank; }); std::vector<Card> result; HandRank bestRank = HighCard; // 枚举 7 选 5,共 21 种组合 for (int a = 0; a < 3; ++a) for (int b = a + 1; b < 4; ++b) for (int c = b + 1; c < 5; ++c) for (int d = c + 1; d < 6; ++d) for (int e = d + 1; e < 7; ++e) { std::vector<Card> combo = { cards[a], cards[b], cards[c], cards[d], cards[e] }; HandRank rank = evaluateCombo(combo); if (rank > bestRank) { bestRank = rank; result = combo; } } return result; }逻辑说明:五层循环枚举所有index组合,evaluateCombo对5张牌做牌型判定。std::sort先把牌按点数从大到小排好,后续顺子、踢脚比较都建立在有序序列上。
参数说明:cards的长度固定为7:2张手牌+5张公共牌。如果以后要做奥马哈(4张手牌),这里的组合数会变成C(9,5)=126,依然很快。evaluateCombo返回值是HandRank,比较用>即可,因为枚举定义时就是从低到高排列的。
evaluateCombo的实现要点:
HandRank evaluateCombo(std::vector<Card>& c) { // c 已按 rank 降序 bool isFlush = (c[0].suit == c[1].suit && c[1].suit == c[2].suit && c[2].suit == c[3].suit && c[3].suit == c[4].suit); bool isStraight = true; for (int i = 0; i < 4; ++i) if (c[i].rank != c[i+1].rank + 1) { isStraight = false; break; } // 特判 A-2-3-4-5 小顺子 if (!isStraight && c[0].rank == 12 && c[1].rank == 3 && c[2].rank == 2 && c[3].rank == 1 && c[4].rank == 0) isStraight = true; if (isStraight && isFlush) return c[0].rank == 12 ? RoyalFlush : StraightFlush; int rankCount[13] = {0}; for (const auto& card : c) rankCount[card.rank]++; int maxRepeat = 0; for (int i = 0; i < 13; ++i) maxRepeat = std::max(maxRepeat, rankCount[i]); // 四条 / 葫芦 if (maxRepeat == 4) return FourOfAKind; bool hasThree = false, hasPair = false; for (int i = 0; i < 13; ++i) { if (rankCount[i] == 3) hasThree = true; if (rankCount[i] == 2) hasPair = true; } if (hasThree && hasPair) return FullHouse; if (isFlush) return Flush; if (isStraight) return Straight; if (hasThree) return ThreeOfAKind; int pairCount = 0; for (int i = 0; i < 13; ++i) if (rankCount[i] == 2) pairCount++; if (pairCount == 2) return TwoPair; if (pairCount == 1) return OnePair; return HighCard; }逻辑说明:先判同花和顺子。A-2-3-4-5是唯一一种A当最小牌的顺子,必须单独特判,否则会漏判。然后根据点数出现次数判断葫芦、三条、两对等组合。
参数说明:c传入前必须按rank降序排列,函数内部依赖这个顺序做相邻对比。rankCount是长度13的数组,索引就是点数,统计每张牌可能出现多次的情况。RoyalFlush严格来说是StraightFlush里以A结尾的一种,单独列出来是为了显示效果,比大小沿用StraightFlush的数值即可——两种牌型值相同也不影响正确性,因为HandRank里它本身就是最大值。
踢脚比较的细节:bestHand返回的result本身就是按点数降序的5张牌,比较时直接逐位比rank。FullHouse比完三条再比对子,TwoPair比完两对再比单张,这些判定在rankCount里已经隐含了,不需要额外写逻辑。
3.3 洗牌用 std::shuffle + 真随机种子:别用 rand()%52
C++ 的rand() % 52在洗牌场景有两个问题:低位的随机性差,而且不设种子的话每次启动序列都一样。Qt 的qrand()本质也是线性同余,同样有这个问题。C++11 起直接用<random>库:
#include <random> void Deck::shuffle() { static std::mt19937 rng(std::random_device{}()); std::shuffle(cards_.begin(), cards_.end(), rng); }逻辑说明:std::random_device{}()取一个系统真随机种子,初始化静态的mt19937对象。static保证整个程序生命周期里只有这一个生成器,不会每次洗牌都重新播种导致重复序列。std::shuffle直接对容器做随机重排,算法本身是均匀的。
参数说明:如果部署环境不支持std::random_device(个别嵌入式环境会退化成伪随机),兜底方案是用std::chrono::high_resolution_clock::now().time_since_epoch().count()做种子。mt19937的内存占用约5KB,持久化为静态完全没有负担。
3.4 回合状态机的最小实现:阶段推进与轮转
用枚举定义五个阶段,Table持有一个成员变量stage_。每个阶段开始时,根据阶段决定发几张公共牌:Flop发3张,Turn和River各发1张。同时重置currentBet_和lastRaiseIdx_,第一个行动玩家是庄家下一位(Preflop则是大盲下一位)。
void Table::enterNextStage() { stage_ = static_cast<GameStage>(stage_ + 1); currentBet_ = 0; lastRaiseIdx_ = -1; switch (stage_) { case GameStage::Flop: dealCommunityCards(3); break; case GameStage::Turn: case GameStage::River: dealCommunityCards(1); break; default: break; } // 结算未结束则指定第一个行动玩家 if (stage_ != GameStage::Showdown) setFirstActor(); }逻辑说明:stage_从Preflop开始,每调用一次推进一格。底池重置在当前阶段开始时做,是因为每个阶段的跟注基准不一样,上一阶段的下注额不能带到下一阶段。
参数说明:setFirstActor的逻辑在Preflop和后续阶段不同:Preflop是大盲注下家的玩家先行动(通常索引是(bigBlindIdx_ + 1) % playerCount),其他阶段是庄家下家先行动。这里的% playerCount要与跳过弃牌玩家结合,否则会把已弃牌的人算进轮转里。我通常写一个nextActivePlayer(int fromIdx)工具函数,内部循环查找还没弃牌且未AllIn的玩家。
4. Qt/C++联调避坑:环境、随机种子与界面卡死的常见问题排查
4.1 装好Qt启动报错:qt.qpa.plugin: could not find the Qt platform plugin "linuxfb"
现象:在Linux开发板上或某些精简环境里,编译好的程序双击运行,终端报错qt.qpa.plugin: could not find the Qt platform plugin "linuxfb",窗口起不来。
原因:Qt的图形后端是按插件加载的,程序找不到platforms目录下的插件库。常见于两处:一是用apt装的Qt库与源码编译版混用,插件路径对不上;二是构建时没有把Qt的插件目录复制到可执行文件旁边,程序靠相对路径找不到。
解决:先用qmake -query QT_INSTALL_PLUGINS查到实际插件路径,然后确认程序运行时能访问到它。最省事的是在启动脚本里显式设置环境变量:
提示:桌面开发时用
xcb插件,嵌入式无显示环境用linuxfb或offscreen。调试阶段用offscreen可以不开显示器跑逻辑测试。
export QT_QPA_PLATFORM_PLUGIN_PATH=/usr/lib/x86_64-linux-gnu/qt5/plugins export QT_QPA_PLATFORM=xcb ./TexasHoldem如果场景是开发板,没有显示设备,就把QT_QPA_PLATFORM设成offscreen,程序能跑但看不见界面。做单元测试时我基本都用offscreen,这样不用占显示器。
4.2 信号槽连不上或重复触发:连接方式和接收者生命周期
现象:按钮点击第一次生效,第二次界面整个没反应;或者一个信号被触发了两次,底池金额翻倍。
原因:第一种情况通常是connect时没传context对象,而且发送者是局部变量,函数退出后对象销毁,信号自然收不到。第二种是connect写法用了重载却没指定函数指针,或者同一对信号槽连接了两遍(比如在showEvent里连接而非构造函数里)。
解决:所有connect必须指定接收者上下文,并且接收者必须是存活期覆盖到信号触发时的对象:
connect(ui->btnCall, &QPushButton::clicked, this, [this]() { emit playerAction(Action::Call, currentCallAmount_); });逻辑说明:第二参数this是接收者上下文。如果this被子类提前销毁,连接自动断开,避免悬垂指针回调。这里还用了emit发出自定义信号,不要把逻辑直接写在按钮的lambda里——按钮层只做“用户点了Call”,怎么处理是Table的事。
排查方法:在connect返回处检查布尔值,Qt 5里connect返回QMetaObject::Connection,可以if (!conn) qWarning() << "connect failed";。更省事的是在槽函数开头qDebug()打印入参,先判断信号有没有进来,再判断逻辑对不对。
4.3 界面卡死、update() 不刷新
现象:点击“发牌”按钮后整个窗口变白/转圈,过几秒才恢复,期间按钮全没反应。加了update()调用也不重绘。
原因:在GUI线程里执行了长耗时操作,比如sleep(1)、每张牌QThread::msleep(200)模拟发牌动画,或者写了一个大的for循环。Qt的事件循环被阻塞,update()只是向事件循环投递一个重绘请求,循环不返回就永远不会重绘。
解决:耗时计算放工作线程,发牌动画可以拆分到QTimer里逐步执行。举例:发牌动画用QTimer每隔300ms发一张牌,而不是一次性发完并sleep:
timer_ = new QTimer(this); timer_->setInterval(300); connect(timer_, &QTimer::timeout, this, [this]() { if (cardsToDeal_.isEmpty()) { timer_->stop(); return; } dealOneCard(cardsToDeal_.takeFirst()); }); timer_->start();逻辑说明:QTimer是事件驱动的,每次超时在事件循环里触发,发一张牌后控制权交还给事件循环,界面得以重绘。注意timer_成为成员变量,避免局部QTimer在函数返回后被销毁。
参数说明:setInterval单位是毫秒。动画效果建议300~400ms发一张,低于200ms人眼感受不到“逐张翻牌”,反而显得生硬。如果AI计算量很大,用QtConcurrent::run放进线程池,结果通过信号传回主线程更新界面。
4.4 每次启动发牌顺序一样:随机种子翻车现场
现象:Debug模式下每次启动程序,前几手牌完全一样,手牌雷同到以为是Bug。
原因:用了srand(NULL)或干脆没设置随机数种子。rand()的默认种子是固定常量,每次都从同一个状态开始。如果用了srand(time(0))且在同一秒内启动多次,序列也会重复。
解决:回到第3.3节的做法,std::random_device + std::mt19937。另外要注意Qt的qrand()需要qsrand()初始化,和标准库的srand不是一套东西,不要混用。
验证方法:写一个单元测试,洗1000次牌,统计每张牌出现在第i个位置的概率,应该均匀分布。快速检查:连续两次洗牌结果一样,就是随机源失效了。
4.5 版本混用报错:fatal: cannot mix incompatible Qt library
现象:编译通过,链接或运行时报告fatal: cannot mix incompatible Qt library (version ex50601) with this library之类的版本不匹配错误。
原因:系统存在两套Qt。常见于 macOS 或 Linux 上用brew/apt装了一套Qt,又手动装了一个新版(比如5.15.2),qmake指向A、CMAKE_PREFIX_PATH指向B,编译头文件和运行库版本对不上。
解决:统一构建工具链。用 CMake 时就显式指定前缀路径,不要依赖系统默认:
cmake -DCMAKE_PREFIX_PATH=/opt/Qt/5.15.2/gcc_64 ..排查时先打印版本确认:
qmake -v pkg-config --modversion Qt5Widgets两个命令的版本不一样,就说明环境变量 PATH 或 PKG_CONFIG_PATH 里混了两套路径。清掉多余的路径再重新配置构建。团队协作时,把Qt安装路径写进CMakeLists.txt的缓存项,或者用Qt5.15.2官方安装器的MaintenanceTool统一版本,能省去这类“玄学错误”。
5. 再进一步:给AI对手加两档难度,顺带把项目移植到Android验证
毕设答辩时,AI对手的决策水平往往比界面华丽度更让老师感兴趣。做一个能用的AI,核心就是“评估手牌强度 + 映射到动作”。低档AI用随机决策加点跟注倾向;高档AI把手牌强度分档后按策略表行动。
int aiPlayerDecision(const std::vector<Card>& handAndBoard) { auto best = bestHand(handAndBoard); HandRank rank = evaluateCombo(best); bool hasPair = (rank >= OnePair); bool hasStrong = (rank >= ThreeOfAKind); // 高档 AI:强牌加注,中等牌跟注,弱牌弃牌 if (hasStrong) return Action::Raise; if (hasPair) return Action::Call; return Action::Fold; }逻辑说明:这是极简版策略,没有考虑底池赔率、位置和对手风格,但对毕业设计里的“打败新手玩家”足够。真正要完善,得引入底池赔率计算和翻牌前后的不同策略权重,可以后续分档。
把项目移植到Android验证时,重点不是界面适配,而是确认逻辑层与界面层分离是否彻底。Widgets在手机上能用,但触摸事件和布局密度都不理想,真要发到手机上用,得切Qt Quick。如果毕设只需要“跨平台”,用android_arm64_v8a的kit编译一遍,能跑通就够了。验证手段是无头测试:用offscreen平台跑10000次模拟对局,统计牌型分布是否符合概率,平均每局耗时不超50ms,说明AI性能够用。
我的血泪经验是:当初为了提升AI智商,写了个每次行动都重算所有玩家隐藏手牌的评估函数,结果一局Think时间赶上手动画完一整副牌,界面卡成幻灯片。后来改成只评估自己和公共牌,AI在信息不全的前提下做决策,反而更接近真实扑克。这个教训记住了,后面再做任何回合制游戏,我都先把“决策时能看什么数据”画清楚,再动手优化算法。希望帮到你。
本文还有配套的精品资源,点击获取