用Qt实现斗地主课设:牌型判定与AI策略核心详解
2026/9/14 1:50:18 网站建设 项目流程

简介:面向C++与Qt学习者的期末课程设计参考资源,选取单机版斗地主这一经典项目,演示从游戏规则到图形界面的完整实现。资源包含119个文件,其中90张PNG图片用于界面与扑克牌面显示,12个C++源文件与11个头文件承载游戏控制、卡牌类、玩家类等核心逻辑,另有3个UI文件用于界面布局,以及pro工程文件、qrc资源文件和图标文件,压缩包整体5.76MB,目录清晰便于定位代码模块。目前已有97人学习。该项目覆盖洗牌、发牌、出牌、牌型比较、胜负判定等完整规则,并实现了电脑AI出牌策略与交互操作界面,读者通过阅读源码可掌握Qt信号槽、事件处理、自定义控件绘制和基础AI算法。源码可直接编译运行,适合用于课程作业方案参考、Qt应用开发入门练习或毕业设计素材。

1. 把斗地主写进 Qt 课设,先想清楚它比通讯录难在哪

很多人的 C++ 期末大作业还在做图书管理系统、学生成绩单,能写一个带图形界面、带 AI 对手的斗地主小游戏,在答辩时是完全不同的观感。这个项目的源码结构里能看到gamecontrol.cppcardpanel.cppbuttongroup.cppuserplayer.cpp这一串文件,其实已经透露了它不是一个"界面套逻辑"的玩具,而是把牌型系统、游戏状态机、AI 出牌策略和 QPainter 绘制拆开实现的完整项目。

单机版斗地主真正难的不是画界面,而是牌型判定和 AI 决策。洗牌、发牌、叫地主这些用 Qt 的随机数和信号槽就能搭起来,但"这手牌能不能压住上家""AI 现在该出单张还是顺子"才是拉开差距的地方。这篇博文按源码的核心模块拆开讲:先看 Card/Cards 怎么表达一手牌,再讲 GameControl 怎么驱动回合流转,然后是 CardPanel 的重绘机制,最后给出牌型判定的自检套路和 AI 出牌策略的调参经验。适合正在做 Qt 课设、或者想把手头项目从"能跑"改到"经得起问"的人。

2. Card/Cards 牌型系统:用位运算把斗地主规则写干净

2.1 为什么把"一张牌"单独做成类

源码里有card.cppcards.cpp,说明设计者没有直接用QString表示牌面,而是用类封装了花色和点数。这是对的,因为斗地主后续所有逻辑都依赖"点数比较"和"花色冗余",如果牌面用字符串"红桃5"表示,每次比较都要解析字符串,代码会很难看。

常见的做法是让 Card 持有两个枚举成员:花色和点数。点数枚举直接按 3 到 2 排,不要按扑克牌的 A、K、Q 排列,因为斗地主里 2 最大、3 最小,这个顺序和自然扑克不同,单独定义枚举可以避免到处写映射函数。

2.1.1 用 int 打包牌面信息
class Card { public: enum Suit { Spade, Heart, Club, Diamond }; enum Point { Card_3, Card_4, Card_5, Card_6, Card_7, Card_8, Card_9, Card_10, Card_J, Card_Q, Card_K, Card_A, Card_2, Card_SJ, Card_BJ }; Card() {} Card(Suit suit, Point point) : m_suit(suit), m_point(point) {} int toInt() const { return m_point * 16 + m_suit; } // 低位4bit存花色,高位存点数 Point point() const { return m_point; } private: Suit m_suit; Point m_point; };

toInt()这个函数值得多说一句:把点数和花色打包成 int 后,Cards 内部可以用QSet<int>存牌,比较两个玩家手牌时直接做集合运算,效率高而且代码简洁。后面判断"是否包含某张牌""剩下哪些牌"都会用到。如果只在界面层用,这个函数可有可无,但要做牌型组合搜索时它非常有用。点数的数值越大代表牌越大,这个约定贯穿整个项目,AI 比较牌力大小就是拿这个值做差值。

2.2 记录"牌型"而不是记录"牌面"

斗地主出牌时,逻辑上应该先判断这组牌属于哪种牌型,再比较大小。源码里虽然没有直接给出CardType的定义,但按这个项目的复杂度,gamecontrol.cpp里一定维护着一个牌型枚举,通常是:单张、对子、三条、三带一、三带二、顺子、连对、飞机、炸弹、王炸。

enum CardType { Invalid, // 非法牌型 Single, // 单张 Pair, // 对子 Triple, // 三条 TripleWithSingle, // 三带一 TripleWithPair, // 三带二 Straight, // 顺子,5张起 StraightPair, // 连对,3连起 Plane, // 飞机不带翼 PlaneWithSingle, // 飞机带单 PlaneWithPair, // 飞机带对 Bomb, // 炸弹 Rocket // 王炸 };

判断牌型的核心方法是getCardType(const Cards& cards),它的输入是玩家选中的一组牌,输出是牌型枚举。实现思路是先把所有牌按点数分组,统计每个点数出现的次数,然后按"数量"和"连续点数"两个维度去匹配。

2.2.1 分组计数加连续点数检测
CardType getCardType(const Cards& cards) { if (cards.count() == 0) return Invalid; QMap<int, int> pointCount; // 点数 -> 出现次数 foreach (int cardValue, cards.toIntList()) { int point = cardValue / 16; pointCount[point]++; } QList<int> points = pointCount.keys(); std::sort(points.begin(), points.end()); // 统计各出现次数的点数列表 QList<int> singleList, pairList, tripleList; int bombCount = 0; foreach (int p, points) { int cnt = pointCount[p]; if (cnt == 1) singleList << p; else if (cnt == 2) pairList << p; else if (cnt == 3) tripleList << p; else if (cnt == 4) bombCount++; } // 王炸判定:大小王同时出现 if (cards.count() == 2 && singleList.size() == 2) { int maxP = qMax(singleList[0], singleList[1]); int minP = qMin(singleList[0], singleList[1]); if (maxP == Card::Card_BJ && minP == Card::Card_SJ) return Rocket; } // 顺子判定:全部为单张且点数连续 if (singleList.size() == cards.count() && singleList.size() >= 5) { bool continuous = true; for (int i = 1; i < singleList.size(); ++i) { if (singleList[i] - singleList[i-1] != 1) { continuous = false; break; } } if (continuous && singleList.last() <= Card::Card_A) return Straight; } // ... 其余牌型判定类似 return Invalid; }

这段代码里有一个容易忽略的细节:顺子判定要限制最大点数为 A,因为 2 和大小王不能进顺子。这个约束很多初学者会漏掉,导致 "A-2-3-4-5" 被错误地判成顺子。逻辑上,判断顺序应该是先处理特殊牌型(王炸、炸弹),再处理普通牌型,最后才判断是否合法。这个函数在源码里的地位相当于整个项目的"规则引擎",所有玩家出牌都会走这里,它的正确性直接决定游戏能不能正常运行。

2.3 牌型比较器的两个注意点

有了牌型类型之后,canBeat判断就变得很直白:先用点数最大值比较同一牌型的大小,再处理炸弹和王炸的跨牌型压制。

bool canBeat(const Cards& lastCards, const Cards& myCards) { CardType lastType = getCardType(lastCards); CardType myType = getCardType(myCards); if (myType == Rocket) return true; // 王炸最大 if (myType == Bomb && lastType != Bomb) return true; // 炸弹压制非炸弹 if (myType != lastType) return false; return maxPoint(myCards) > maxPoint(lastCards); }

比较时候选牌的最大点数必须用同一个标准:三带一带的是单张,比较大小看的是三条部分,不是带的单张。如果直接取整手牌最大点数,三带一可能被带的牌误导。正确做法是在取点数时,先按出现次数排序取主牌部分。这个 bug 很隐蔽,我见过不少人的斗地主项目在这里翻车,看似能跑,实际对局里会出现"K 带 3 压住了 Q 带 A"这种非法结果。

牌型主牌部分大小比较基准备注
三带一三条三条点数带的单张不参与大小比较
三带二三条三条点数带的对子不参与
顺子全部单张最大单张点数长度必须相等
连对全部对子最大对子点数长度必须相等
飞机带翼连续三条最大三条点数带牌数量要匹配

牌型系统的设计在源码里可以反复看到"先分组建表,再查表判断"的思路,这也是处理有限规则博弈的通用方法,后面 AI 出牌搜索时同样依赖这里生成的统计结构。

3. GameControl 与 Player 类:状态机驱动回合流转

3.1 GameControl 在游戏里的角色

gamecontrol.cpp是整个游戏的心脏,它不负责画界面,而是维护游戏的状态、持有三个玩家对象、管理发牌和出牌的顺序。阅读这个文件时,会发现它大量使用 Qt 的信号槽机制:界面层的按钮点击、玩家对象的出牌动作、AI 的计算延时,都通过信号连接起来。

游戏的核心状态可以抽象成枚举:发牌中、叫地主中、出牌中、结算中。从发牌结束到叫地主,是一个典型的"顺序轮流"过程,每轮每个玩家选择叫地主或抢地主,最后地主确定后从地主开始出牌。这个过程在 Qt 里的标准写法是 connect 玩家的信号到 GameControl 的槽函数,每个玩家出完牌后 GameControl 决定下一个行动者,而不是用循环阻塞。

游戏流程: 洗牌 -> 发牌 -> 叫地主/抢地主 -> 确定地主与底牌 -> 地主先出 -> 轮转出牌 -> 有人出完 => 结算

3.2 玩家类如何抽象"人"和"电脑"

源码里有player.cppuserplayer.cpp,这说明基类 Player 定义了所有玩家共有的行为,UserPlayer 是真人玩家的具体实现。基类里最重要的虚函数是think()或者playHand(),真人玩家由界面按钮触发,电脑玩家由算法决策。

class Player : public QObject { Q_OBJECT public: explicit Player(QObject* parent = nullptr); void setCards(const Cards& cards); // 设置手牌 Cards getCards() const; // 获取手牌 void clearCards(); virtual void startThink() = 0; // 纯虚函数,子类实现决策 signals: void cardsPlayed(const Cards& cards); // 出牌信号 void pass(); // 不出信号 protected: Cards m_cards; bool m_isLandlord; }; class UserPlayer : public Player { Q_OBJECT public: void startThink() override { // 玩家等待界面点击,不需要主动决策 emit cardsPlayed(selectedCards); } }; class AIPlayer : public Player { Q_OBJECT public: void startThink() override { // 计算最优出牌组合,延迟 800ms 模拟思考 QTimer::singleShot(800, this, [=]() { Cards best = calculateBestMove(); if (best.isEmpty()) { if (canPass()) emit pass(); else emit cardsPlayed(minCards); } else { emit cardsPlayed(best); } }); } };

这套设计的巧妙之处在于把"什么时候出牌"和"出什么牌"解耦了。GameControl 只需要调用当前玩家的startThink(),然后等待cardsPlayedpass信号,真人玩家和 AI 玩家行为完全统一。对于期末大作业来说,这种设计的答辩价值很高,老师顺着问下来,你能讲清楚"为什么出牌逻辑不直接写在 GameControl 里",就比大多数同学高出一截。

3.3 AI 出牌策略:最少牌数优先

userplayer.cpp是真人玩家,出牌由界面决定;真正的难点在 AI 玩家的calculateBestMove()。这里不展开所有细节,只讲一个能跑且有一定强度的手牌搜索思路:当 AI 需要压住上家时,先生成所有能压住的手牌组合,再按牌数最少优先、同牌数点数最小优先的策略选一手。

AI 选择策略通常有两大类:一类是"跟牌最少",适合残局阶段快速把手牌打完;一类是"保留大牌",适合前期压制。简单的实现是组合试探,从拆牌的角度枚举顺子、连对、飞机等组合,然后用贪心策略决定出哪一手。源码里没有 AI 策略的具体实现,作为一个合格的项目,AI 至少要能做到:能出牌时不出错牌、不能出牌时不乱出、先出完牌获胜。

4. CardPanel 与重绘机制:QPainter 绘制卡牌界面

4.1 为什么选择自绘而不是 QLabel 贴图

cardpanel.cpp是界面层的核心,负责显示玩家手牌和出牌区的牌。如果用 54 张 QLabel 显示牌面,虽然实现简单,但会遇到两个问题:一是拖动、选中、排列的交互逻辑分散在各处,代码难以维护;二是窗口缩放时牌的布局需要重新计算,QLabel 需要手动移动位置,容易出错。

源码中 CardPanel 继承自 QWidget,通过paintEvent()自绘,这是 Qt 里实现卡牌类游戏的常用方式。每张牌用一个矩形区域表示,点击时牌向上偏移 20 像素表示选中,绘制时用 QPainter 画圆角矩形、画花色符号和点数。

4.1.1 paintEvent 绘制单张牌
void CardPanel::paintEvent(QPaintEvent* event) { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, true); int cardWidth = 100; int cardHeight = 140; int spacing = 30; // 牌与牌之间的水平偏移 // 计算总宽度,让牌组水平居中 int totalWidth = m_cards.count() * spacing + cardWidth; int startX = (width() - totalWidth) / 2; for (int i = 0; i < m_cards.count(); ++i) { int x = startX + i * spacing; int y = m_selected.contains(i) ? 20 : 50; // 选中牌上移 drawCard(&painter, x, y, cardWidth, cardHeight, m_cards[i]); // 如果只有最后一张牌,补足到完整宽度 if (i == m_cards.count() - 1) { drawCard(&painter, x + spacing, y, cardWidth, cardHeight, m_cards[i]); } } }

绘制函数drawCard里有一个容易踩的坑:绘制牌面文字时要用painter.drawText()配合合适的字体大小,花色符号直接用 Unicode 字符(黑桃 U+2660、红心 U+2665、梅花 U+2663、方片 U+2666),不要用图片资源,否则会因为图片路径问题导致发布后的程序找不到资源。

4.2 鼠标事件与牌的选择交互

CardPanel还承担了鼠标交互的功能。玩家点击某张牌时,需要把这张牌加入选中的集合,然后调用update()触发重绘。注意不是直接用repaint(),因为update()会合并多次重绘请求,避免界面卡顿。

void CardPanel::mousePressEvent(QMouseEvent* event) { int index = cardIndexAt(event->pos()); if (index < 0) return; if (m_selected.contains(index)) { m_selected.remove(index); } else { m_selected.insert(index); } update(); // 请求重绘,显示选中状态变化 // 发送信号,让外部知道用户选择了哪些牌 emit cardsSelected(getSelectedCards()); }

命中检测cardIndexAt的逻辑注意边界:斜着排列的牌,点击了上一张牌的可见区域和下一张牌的重叠区域时,应该优先选中哪一张,需要定义清楚规则。常见做法是优先选中点击点所在矩形中心距离最近的牌,这样手感最好。这个交互做不好,玩家会觉得点牌没反应,实际上就是命中区域计算有问题。

4.3 ButtonGroup 和 ScorePanel 的协作

buttongroup.cpp负责"出牌""不出""提示"这几个按钮的显隐控制。这里有一个 Qt 新手容易忽视的点:按钮组应该在拿到优先出牌权时禁用"不出",而在跟牌时启用"不出"。这个状态切换在 GameControl 里通过调用buttongroup->setButtonsVisible()完成。

scorepanel.cpp则负责显示三个玩家的分数和地主标志,通常用 QPainter 绘制简单的数字和图标,或者用 QLabel 设置样式表。上家和对家的手牌数量也要在这里显示,用cards.count()实时更新,避免玩家看到对方的手牌内容。

5. 牌型判定自检与 AI 出牌策略的调参技巧

5.1 构造"牌堆快照"来做单元验证

程序写完不等于逻辑正确,斗地主这种规则密集的项目,最有效的方式是写一个独立的测试入口,手工构造玩家的手牌,然后验证getCardTypecanBeat的输出。

void testCardLogic() { Cards hand; hand.add(Card(Card::Spade, Card::Card_3)); hand.add(Card(Card::Heart, Card::Card_4)); hand.add(Card(Card::Club, Card::Card_5)); hand.add(Card(Card::Diamond, Card::Card_6)); hand.add(Card(Card::Spade, Card::Card_7)); // 这是一个合法顺子 3-4-5-6-7 qDebug() << "expect Straight:" << getCardType(hand); Cards invalidStraight; invalidStraight.add(Card(Card::Spade, Card::Card_10)); invalidStraight.add(Card(Card::Heart, Card::Card_J)); invalidStraight.add(Card(Card::Club, Card::Card_Q)); invalidStraight.add(Card(Card::Diamond, Card::Card_K)); invalidStraight.add(Card(Card::Spade, Card::Card_A)); invalidStraight.add(Card(Card::Heart, Card::Card_2)); // 2 不该出现在顺子里 qDebug() << "expect Invalid:" << getCardType(invalidStraight); }

这类测试数据尽量覆盖边界:顺子最小长度 5、三带一的带牌是 2、炸弹对王炸的压制关系、以及 A-2 不能进顺子。每发现一个错误,就把它变成一条测试用例,后面改代码时回归成本极低。对于期末答辩,你能拿出"我测过这些边界情况"的证据,比单纯演示游戏要有说服力得多。

5.2 AI 强度调节的实战参数

AI 开局时有牌必出,容易被玩家看出规律,建议在AIPlayer::startThink()里加一个"策略概率"参数:当 AI 手牌多于 10 张时,不跟小牌,保留实力;少于 5 张时,全力出完手牌。这个参数直接控制游戏难度,参数取 0 到 1 之间的值,按百分比决定 AI 是否选择"最优解"以外的打法。

另外,AI 拆牌时机很重要。手上有三张 8,玩家出了一对 5,AI 要不要拆掉三条 8 去压?经验是:AI 手牌少于 8 张时不拆,多于 8 张时拆了对子更划算。这类经验值建议做成成员变量,便于答辩时现场演示不同难度档位下的行为差异。

5.3 排查"选出牌点不了出牌按钮"的常见路径

这类 bug 多半不是按钮的问题,而是cardsPlayed发送的牌型为 Invalid,GameControl 的槽函数里把非法出牌拦截了。定位的时候在getCardType返回 Invalid 的分支加qDebug()打印选中的牌,能快速判断是牌型判定函数的问题,还是组合枚举的问题。

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

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

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

立即咨询