ARTICLE DETAIL

资讯详情

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

用Qt实现斗地主课设:牌型判定与AI策略核心详解

用Qt实现斗地主课设:牌型判定与AI策略核心详解 简介面向C与Qt学习者的期末课程设计参考资源选取单机版斗地主这一经典项目演示从游戏规则到图形界面的完整实现。资源包含119个文件其中90张PNG图片用于界面与扑克牌面显示12个C源文件与11个头文件承载游戏控制、卡牌类、玩家类等核心逻辑另有3个UI文件用于界面布局以及pro工程文件、qrc资源文件和图标文件压缩包整体5.76MB目录清晰便于定位代码模块。目前已有97人学习。该项目覆盖洗牌、发牌、出牌、牌型比较、胜负判定等完整规则并实现了电脑AI出牌策略与交互操作界面读者通过阅读源码可掌握Qt信号槽、事件处理、自定义控件绘制和基础AI算法。源码可直接编译运行适合用于课程作业方案参考、Qt应用开发入门练习或毕业设计素材。1. 把斗地主写进 Qt 课设先想清楚它比通讯录难在哪很多人的 C 期末大作业还在做图书管理系统、学生成绩单能写一个带图形界面、带 AI 对手的斗地主小游戏在答辩时是完全不同的观感。这个项目的源码结构里能看到gamecontrol.cpp、cardpanel.cpp、buttongroup.cpp、userplayer.cpp这一串文件其实已经透露了它不是一个界面套逻辑的玩具而是把牌型系统、游戏状态机、AI 出牌策略和 QPainter 绘制拆开实现的完整项目。单机版斗地主真正难的不是画界面而是牌型判定和 AI 决策。洗牌、发牌、叫地主这些用 Qt 的随机数和信号槽就能搭起来但这手牌能不能压住上家AI 现在该出单张还是顺子才是拉开差距的地方。这篇博文按源码的核心模块拆开讲先看 Card/Cards 怎么表达一手牌再讲 GameControl 怎么驱动回合流转然后是 CardPanel 的重绘机制最后给出牌型判定的自检套路和 AI 出牌策略的调参经验。适合正在做 Qt 课设、或者想把手头项目从能跑改到经得起问的人。2. Card/Cards 牌型系统用位运算把斗地主规则写干净2.1 为什么把一张牌单独做成类源码里有card.cpp和cards.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 内部可以用QSetint存牌比较两个玩家手牌时直接做集合运算效率高而且代码简洁。后面判断是否包含某张牌剩下哪些牌都会用到。如果只在界面层用这个函数可有可无但要做牌型组合搜索时它非常有用。点数的数值越大代表牌越大这个约定贯穿整个项目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; QMapint, int pointCount; // 点数 - 出现次数 foreach (int cardValue, cards.toIntList()) { int point cardValue / 16; pointCount[point]; } QListint points pointCount.keys(); std::sort(points.begin(), points.end()); // 统计各出现次数的点数列表 QListint 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.cpp和userplayer.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()然后等待cardsPlayed或pass信号真人玩家和 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 字符黑桃 U2660、红心 U2665、梅花 U2663、方片 U2666不要用图片资源否则会因为图片路径问题导致发布后的程序找不到资源。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 构造牌堆快照来做单元验证程序写完不等于逻辑正确斗地主这种规则密集的项目最有效的方式是写一个独立的测试入口手工构造玩家的手牌然后验证getCardType和canBeat的输出。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玩家出了一对 5AI 要不要拆掉三条 8 去压经验是AI 手牌少于 8 张时不拆多于 8 张时拆了对子更划算。这类经验值建议做成成员变量便于答辩时现场演示不同难度档位下的行为差异。5.3 排查选出牌点不了出牌按钮的常见路径这类 bug 多半不是按钮的问题而是cardsPlayed发送的牌型为 InvalidGameControl 的槽函数里把非法出牌拦截了。定位的时候在getCardType返回 Invalid 的分支加qDebug()打印选中的牌能快速判断是牌型判定函数的问题还是组合枚举的问题。本文还有配套的精品资源点击获取
返回列表