简介:一份基于C++与Qt框架实现的单机版斗地主期末大作业,适合C++初学者、Qt应用开发学习者以及需要完成类似课程设计的高校学生。项目完整覆盖洗牌、发牌、出牌规则、牌型判断、胜负结算等核心游戏逻辑,并提供了图形化的对战界面与人机AI对手,AI会依据手牌与出牌局势进行策略决策,使玩家无需联网即可体验完整对局。资源包共119个文件、约5.76MB,包含12个.cpp源码、11个.h头文件、3个.ui界面文件及.pro、.qrc等工程配置文件,另有90张png图片负责牌面、按钮和场景渲染。目录结构符合标准Qt工程组织方式,各游戏模块独立清晰,便于阅读和二次开发。目前已有98人学习下载,适合作为期末答辩或Qt实战项目的参照。通过阅读gamePanel、cards、userPlayer等关键源码,可以具体掌握C++类设计、Qt控件布局与信号槽通信、出牌逻辑判定的实现方法,以及AI自动出牌的基本思路,是一份能直接运行、可拆解学习的综合实践资料。
1. C++ 期末大作业里最常见的 Qt 单机斗地主,考的不只是写界面
“C++ 期末大作业-Qt实现单机版斗地主小游戏-2025”这个标题,看起来是把扑克搬上屏幕,实际上一次把 Qt、数据结构和算法全考了:洗牌发牌怎么保证不重复,54 张牌怎么编码才能比大小,界面和逻辑怎么解耦,三个玩家轮流出牌的状态怎么驱动。很多人在网上翻“c++ 小游戏源码”直接拷一份,答辩时导师问“这副牌为什么这么编号”就答不上来。
这篇笔记按落地顺序讲:先把斗地主规则翻译成 C++ 数据模型,再写牌型裁判,然后做 Qt 界面,最后补上调试与验证手段。适合正在做期末大作业、想在现场把每一步讲清楚的人。
2. 数据模型先行:牌编码、洗牌发牌和手牌排序怎么定
2.1 用 0~53 的 int 编码还是结构体:斗地主牌型判断更认哪种
我刚做这个题目时,第一反应是定义一个结构体:
struct Card { int rank; // 点数 int suit; // 花色 };写起来很直观,但很快发现问题:每次判断牌型都要拆字段,洗牌时要给结构体数组写交换函数,调试时打印一串{rank: 3, suit: 2}也费劲。期末大作业的时间有限,我后来改成全用int编码:一副牌就是 54 个 0~53 的整数,大小比较、随机洗牌、存盘回放都非常省事。
编码约定是这样定的:0~51 表示普通牌,rank = card / 4,suit = card % 4,其中 rank 的 0~12 依次对应 3、4、5、6、7、8、9、10、J、Q、K、A、2;52 是小王,53 是大王。注意这里 rank 从 3 开始,而不是从 A 开始,因为斗地主里 3 最小。
// 把 0~53 的牌编码换算成逻辑点数和花色 int rankOf(int card) { if (card >= 52) return card - 39; // 52 -> 13(小王), 53 -> 14(大王) return card / 4; } int suitOf(int card) { if (card >= 52) return 4; // 王没有花色,用 4 表示 Joker return card % 4; }这两个函数是整个项目的“地基”。牌面显示、大小比较、牌型识别全部走这里,后续代码里不要到处写card % 4这种裸逻辑,否则换一次编码约定就要全局改。参数说明:rank 最大到 14,所以后面统计点数的数组至少开 15 个位置,我习惯直接int cnt[15] = {0},多一个位置不心疼,少一个就一定越界。
与显示层对应,要把 rank 翻译成字符或图片名:
QString rankText(int card) { if (card == 52) return "小王"; if (card == 53) return "大王"; const char* names[] = {"3","4","5","6","7","8","9","10","J","Q","K","A","2"}; return QString(names[rankOf(card)]); }这就是“逻辑编码、显示文本、牌面比较”三层分离。新手常犯的错是把显示文本当比较依据,比如用字符串比较 “10” 和 “9”,结果永远是 “10” 小,因为字符串按字符挨个比。用 int 编码后完全避开这种问题。
2.2 洗牌发牌:std::shuffle 与 QRandomGenerator 的落地
斗地主的牌库是 54 张无重复牌,最简单的洗牌方式就是把这 54 个整数放进QVector<int>,然后std::shuffle打乱顺序,再按“每人一张轮流发”的方式分出去。
#include <QRandomGenerator> #include <algorithm> QVector<int> createAndShuffleDeck() { QVector<int> deck; deck.reserve(54); for (int i = 0; i < 54; ++i) deck.append(i); // 用 Qt 提供的全局随机数引擎打乱,避免自己写洗牌逻辑 std::shuffle(deck.begin(), deck.end(), *QRandomGenerator::global()); return deck; }这里有个关键选择:不要用qsrand(time(0))加rand()。Qt 5 之后官方就逐步弃用旧随机数接口,Qt 6 里继续用会看到一堆 deprecated 警告。QRandomGenerator::global()是线程安全的全局单例,种子由系统维护,每次启动都不一样,期末考试演示时不用担心“怎么又是同一手牌”。
发牌逻辑也简单,底层本质是循环取i % 3:
void dealCards(const QVector<int>& deck, QVector<int>& hand0, QVector<int>& hand1, QVector<int>& hand2, QVector<int>& dipai) { hand0.clear(); hand1.clear(); hand2.clear(); dipai.clear(); for (int i = 0; i < 51; ++i) { if (i % 3 == 0) hand0.append(deck[i]); else if (i % 3 == 1) hand1.append(deck[i]); else hand2.append(deck[i]); } for (int i = 51; i < 54; ++i) dipai.append(deck[i]); }参数说明:dipai是最后 3 张底牌,单机版可以直接固定归玩家所有,也可以在开局界面弹一个“是否抢地主”的对话框。发完牌后必须对三家手牌都做一次排序,否则后面识别顺子、连对时会乱。
2.3 手牌排序:用 std::sort 而不是冒泡排序
手牌排序最直接的收益是 UI 展示整齐,更重要的是方便牌型识别:同点数的牌会相邻,统计数量一目了然。期末阶段不用手写排序,std::sort加一个自定义比较函数即可。
void sortHand(QVector<int>& hand) { std::sort(hand.begin(), hand.end(), [](int a, int b) { int ra = rankOf(a), rb = rankOf(b); if (ra != rb) return ra < rb; // 点数从小到大 return suitOf(a) < suitOf(b); // 同一张点按花色排,显示更稳定 }); }注意比较函数必须严格弱排序,即相同牌不能交换时返回 true,否则std::sort在极端情况下会越界。这里两个参与比较的牌不可能相等,因为整副牌没有重复编码,所以比较安全。
很多教程喜欢用冒泡排序讲“排序思路”,但在 Qt 项目里手写冒泡只会增加翻车概率,而且性能对 17 张牌毫无意义。按点数升序排好后,底牌加入后只需要重新调用一次sortHand,不用专门做插入操作。
2.4 地主确认与底牌并入:流程上最容易漏的一步
发完牌先别急着进游戏,单机斗地主还需要决定谁当地主。常见做法是随机选一个玩家,或者让玩家自己选。我这里是随机地主,同时把底牌并入地主手牌:
void setLandlord(int landlordIndex, QVector<QVector<int>>& hands, const QVector<int>& dipai, QVector<int>& landlordHand) { // 把底牌合并给地主,并重新排序 landlordHand = hands[landlordIndex]; for (int c : dipai) landlordHand.append(c); sortHand(landlordHand); }这一步完成后,整个牌局的数据准备才算结束。很多新手直接从洗牌跳到出牌界面,底牌留在场上没人处理,或者底牌显示了但没加进地主手牌,导致发牌数不对。建议在GameData这样一个类里统一维护:当前地主编号、三家手牌、底牌、当前出牌人编号,避免把状态散落在各个 UI 控件里。
3. 牌型识别与出牌合法性:把裁判逻辑写成不翻车的 C++ 函数
3.1 先列全牌型表:单张、对子、三带、顺子、连对、飞机、炸弹、火箭
斗地主看着复杂,裁判逻辑其实是一张有限表。先把牌型枚举定义清楚:
enum CardType { Single, // 单张 Pair, // 对子 Triple, // 三张 TripleSingle, // 三带一 TriplePair, // 三带二 Straight, // 顺子 StraightPair, // 连对 PlaneSingle, // 飞机带单 PlanePair, // 飞机带对 Bomb, // 炸弹 Rocket, // 火箭 Invalid };牌型约束需要记牢:顺子至少 5 张,最多 12 张;连对至少 3 个连续对子;飞机带翅膀的情况更复杂。我把常见规则整理成一张表,写代码时对照着填。
| 牌型 | 最少张数 | 关键限制 |
|---|---|---|
| 单张 / 对子 | 1 / 2 | 无 |
| 三张 / 三带一 / 三带二 | 3 / 4 / 5 | 三带二不能带王炸或炸弹?严格规则里可以带一对 |
| 顺子 | 5 | 从 3 到 A,不能含 2 和大小王 |
| 连对 | 6 | 至少 3 个连续对子,同样不含 2 和王 |
| 飞机带单 | 8 | 至少 2 个连续三张,翅膀数量等于三张数量 |
| 飞机带对 | 10 | 至少 2 个连续三张,翅膀为一对一对 |
| 炸弹 | 4 | 四张同点数 |
| 火箭 | 2 | 小王 + 大王 |
3.2 用 cnt[15] 统计点数:牌型判断的第一步
不管什么牌型,第一步都是统计每种点数出现几次。这个数组长度必须足够:rank 最大值是 14,所以要int cnt[15]。我写过一个版本用int cnt[13],结果大小王直接越界,Qt 在调试模式下当场崩掉。
// 统计每种点数出现的次数 int cnt[15] = {0}; for (int c : cards) { int r = rankOf(c); cnt[r]++; } // 顺带统计一下各类基础牌的数量,后面判断三带、飞机用 int singles = 0, pairs = 0, triples = 0, bombs = 0; for (int r = 0; r < 15; ++r) { if (cnt[r] == 1) singles++; else if (cnt[r] == 2) pairs++; else if (cnt[r] == 3) triples++; else if (cnt[r] == 4) bombs++; }参数说明:这里 arrays 的下标是 rank,不是花色。花色对斗地主牌型没有影响,只有确定“能不能出同花色”时才会看,但斗地主根本没有同花顺规则,所以只需要 rank 计数就够了。
3.3 识别函数:从单张到飞机的一步步判断
我习惯把识别函数写成一种“排除法”:先处理最简单的情况,再逐步处理连续型,最后是特殊类型。
struct PlayInfo { CardType type; int key; // 主点数,用于后续压牌比较 int length; // 顺子/连对/飞机的连续长度,其余牌型填 0 }; PlayInfo analyzePlay(const QVector<int>& cards) { PlayInfo p = { Invalid, 0, 0 }; int n = cards.size(); if (n == 0) return p; int cnt[15] = {0}; for (int c : cards) cnt[rankOf(c)]++; // 火箭:小王 + 大王 if (n == 2 && cnt[13] == 1 && cnt[14] == 1) { return { Rocket, 14, 0 }; } // 单张、对子、三张、炸弹这类“整型牌” for (int r = 0; r < 15; ++r) { if (cnt[r] == n) { if (n == 1) return { Single, r, 0 }; if (n == 2) return { Pair, r, 0 }; if (n == 3) return { Triple, r, 0 }; if (n == 4) return { Bomb, r, 0 }; } } // 三带一 / 三带二 if (n == 4 && triples == 1 && singles == 1) return { TripleSingle, tripleRank, 0 }; if (n == 5 && triples == 1 && pairs == 1) return { TriplePair, tripleRank, 0 }; // 连续型牌:顺子、连对、飞机,需要单独写一个函数 // 这里省略连续区间检测细节,下一节展开 return p; }这里有个设计重点:识别结果里只保留“主点数”。比如 3 个 Q 带一张 5,key就是 Q 对应的 rank,压牌时只要比较双方的主点数即可。三带一里带的牌永远参与不了比较,这是规则决定的,代码里也不需要记录它。
完整的顺子检测要单独拆函数,否则analyzePlay会膨胀到没法读。核心逻辑:判断从某个开始 rank 到结束 rank 连续、长度达标、并且范围内不出现 2 和王。
// 判断 [start, end] 区间内每个点数的 cnt 是否都 >= 1 bool isConsecutive(const int cnt[15], int start, int end) { if (start < 0 || start >= 13) return false; // rank 12 是 2,不能进顺子 for (int r = start; r <= end; ++r) { if (cnt[r] != 1) return false; } return true; }注意这里的end也不能超过 12,因为 2 的 rank 是 12,王是 13、14。顺子最大能到 A,A 的 rank 是 11,所以很多实现干脆写成for (int r = 0; r < 12; ++r),无论如何都不让 2 进循环。
3.4 压牌比较:炸弹、火箭和同牌型谁更大
出牌合法性分两层:你得先是个合法牌型,然后还得压过上一手。压牌比较逻辑可以抽象成这样:
bool canBeat(const PlayInfo& last, const PlayInfo& cur) { // 火箭压一切 if (cur.type == Rocket) return last.type != Rocket; if (last.type == Rocket) return false; // 炸弹压除火箭外的所有牌型 if (cur.type == Bomb && last.type != Bomb) return true; if (last.type == Bomb && cur.type != Bomb) return false; // 同牌型才比主点数 if (cur.type != last.type) return false; if (cur.type == Single || cur.type == Pair || cur.type == Triple || cur.type == Bomb || cur.type == TripleSingle || cur.type == TriplePair) { return cur.key > last.key; } // 顺子、连对、飞机必须长度一致,再比起点 return cur.length == last.length && cur.key > last.key; }比较函数里最容易遗漏的是“长度一致”。比如上家出了 34567,你出 45678 是 5 张顺子,点数更大,合法;但如果你出 345678 变 6 张顺子,张数都不对,不合法。同理,连对和飞机也必须保持长度一致。
3.5 边界用例:顺子不含 2、飞机翅膀怎么算
写完识别逻辑后,我建议立刻写几个测试用例,而不是直接接 UI。这里给一份最常用的用例表,直接放进main()里用qDebug()验证:
| 手牌 | 期望结果 |
|---|---|
| 3, 4, 5, 6, 7 | Straight,key=7 |
| 3, 3, 4, 4, 5, 5 | StraightPair,key=5 |
| 3, 3, 3, 4 | TripleSingle,key=3 |
| 3, 3, 3, 4, 4 | TriplePair,key=3 |
| J, Q, K, A, 2 | Invalid,2 不能进顺子 |
| 小王, 大王 | Rocket |
| 3, 3, 3, 3 | Bomb |
我自己翻车最狠的一次就是“JQKA2”判断:一开始isConsecutive检查了开头不小于 0,但结尾跑到了 rank 12(2),导致cnt[12] == 1成立后被误判成合法顺子。加上end < 12条件后这个问题彻底消失。
4. Qt Widgets 实现牌桌:画牌、选牌、出牌、倒计时的完整链路
4.1 Widgets 还是 QML:期末答辩更稳的选择
Qt 做界面有两条常见路线:Qt Widgets 和 QML。期末大作业我强烈建议 Widgets。理由很实际:Widgets 用 C++ 直接写,信号槽机制和你在数据模型层用到的 C++ 知识连贯,调试时断点能直接跟到鼠标点击、绘图这些动作里;QML 虽然动画效果更现代,但要在 JavaScript 和 C++ 之间来回传数据,答辩时很容易被问住。
Qt 界面设计如果追求精美,有人会用 QML,但单机斗地主的核心价值在规则算法,界面只要牌面清晰、点牌顺手就够了。下面这套方案基于 Qt Widgets 加 QPainter 绘图,工作量可控。
4.2 从零画一张扑克牌:paintEvent 里的绘制步骤
每张牌是一个继承自 QWidget 的小控件,宽度 80、高度 112。用paintEvent画圆角矩形和文字,而不是放一张图片,这样不需要额外资源文件。
class CardWidget : public QWidget { Q_OBJECT public: CardWidget(int cardValue, QWidget* parent = nullptr) : QWidget(parent), m_cardValue(cardValue), m_hover(false) { setFixedSize(80, 112); } void setSelected(bool sel) { m_selected = sel; update(); } bool isSelected() const { return m_selected; } protected: void paintEvent(QPaintEvent*) override { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing); int offset = m_selected ? -20 : 0; // 选中时牌面整体上移 20 像素 QRect faceRect(0, offset, 80, 112 - offset); painter.setBrush(m_selected ? QColor("#FDF6E3") : Qt::white); painter.setPen(QPen(Qt::black, 2)); painter.drawRoundedRect(faceRect, 8, 8); // 左上角显示点数和花色 QString text = rankText(m_cardValue); painter.setPen(rankColor(m_cardValue)); painter.setFont(QFont("Arial", 14, QFont::Bold)); painter.drawText(6, 20 + offset, text); painter.drawText(6, 36 + offset, suitSymbol(m_cardValue)); } private: int m_cardValue; bool m_selected = false; };参数说明:offset = -20是把牌面向 y 轴负方向抬高,视觉效果是“这张牌浮起来了”。update()会触发重绘,不要在setSelected里直接调repaint(),它会产生额外开销。花色颜色我在rankColor()里按黑红两色处理。
绘制文字时,“10”这个点数是两个字符,drawText会自动压缩,实测没问题。花色符号可以直接用♠♥♣♦四个 Unicode 字符,Windows 和 Linux 下常见字体都能显示,不用额外找素材。
4.3 点牌选牌与出牌:鼠标事件和信号槽怎么接
选牌交互非常简单:重写mousePressEvent,点击时翻转选中状态。
void CardWidget::mousePressEvent(QMouseEvent* event) { if (event->button() == Qt::LeftButton) { m_selected = !m_selected; update(); emit clicked(m_cardValue, m_selected); } QWidget::mousePressEvent(event); }然后在主窗口里把这些CardWidget放进一个容器QVector<CardWidget*> m_cardWidgets,在创建手牌时按顺序addWidget到QHBoxLayout里。出牌按钮的槽函数要做四件事:收集选中的牌值、转成QVector<int>、调用analyzePlay做合法性检查、通过信号发给游戏逻辑层。
void MainWindow::onPlayClicked() { QVector<int> selectedCards; for (CardWidget* w : m_cardWidgets) { if (w && w->isSelected()) selectedCards.append(w->cardValue()); } if (selectedCards.isEmpty()) { QMessageBox::information(this, "提示", "请先选牌"); return; } PlayInfo info = analyzePlay(selectedCards); if (info.type == Invalid) { QMessageBox::warning(this, "提示", "这不是一个合法牌型"); return; } if (!canBeat(m_lastPlay, info)) { QMessageBox::warning(this, "提示", "压不过上一手"); return; } // 合法,把牌打出去,交给状态机推进回合 emit playRequested(selectedCards); }这个流程把 UI 和规则层分得很清楚:UI 只负责“选了什么牌”,规则层负责“这手牌合不合法”,互不干扰。后面加托管、加提示功能,都不需要改选牌逻辑。
抽牌后要立刻从手牌集合中移除对应的CardWidget并刷新布局。常见坑是只删了数据、没删控件,导致界面越打越长。我会用deleteLater()配合layout->removeWidget()一起处理。
4.4 回合推动与 AI 自动出牌:QTimer 的正确姿势
单机版的“轮到电脑出牌”不能靠sleep()实现。如果你在槽函数里写QThread::msleep(500),整个界面会冻住 0.5 秒,看起来像卡死。正确做法是用QTimer延迟调用。
// 当前轮到某个玩家出牌时,启动一个一次性定时器 QTimer::singleShot(800, this, [this]() { if (m_currentPlayer == 2) { // 玩家 3 是电脑 QVector<int> aiCards = aiChoosePlay(); if (!aiCards.isEmpty()) { emit playRequested(aiCards); } } });QTimer::singleShot是单次定时器,比创建一个长期 timer 再手动 stop 少了很多状态管理。如果要做回合倒计时,就再起一个 1000ms 的重复QTimer,每次timeout让秒数减一,减到零时自动执行“不出”或“出最小牌”。
m_countdownTimer = new QTimer(this); m_countdownTimer->setInterval(1000); connect(m_countdownTimer, &QTimer::timeout, this, [this]() { m_secondsLeft--; ui->labelCountdown->setText(QString("%1 秒").arg(m_secondsLeft)); if (m_secondsLeft <= 0) onPlayClicked(); // 超时自动出牌 });4.5 桌面布局和操作提示:QLayout、QPushButton、QCheckBox
牌桌布局我用三个区域:顶部两个 AI 玩家的出牌区,中间底牌和出牌堆,底部我的手牌区。手牌区必须是水平排列且能容纳 20 张牌,每张 80 宽,20 张就是 1600 像素,建议把牌宽缩小到 70 或用QScrollArea包一层。
按钮区放“出牌”“不出”“提示”三个按钮。简单提示功能的实现方式是:遍历手牌组合,找到第一个能压过上一手的牌型,把对应的CardWidget设为选中状态。这里需要遍历,性能不是问题,17 张牌的组合数很少。
QCheckBox* enableAI = new QCheckBox("AI 托管", this); connect(enableAI, &QCheckBox::toggled, this, [this](bool on) { m_isAITurn = on; });这就是 Qt 里 checkbox 的常规用法,用toggled信号比手动检测isChecked()更干净。期末答辩时,把“AI 托管”打开,三个玩家全自动跑完一局,演示效果会好很多。
5. 常见问题排查:Qt 崩溃、乱码、假随机和出牌误判
5.1 程序一启动就崩溃,断点指向未知位置
现象:运行后窗口还没出现就闪退,Qt Creator 停在“应用程序输出”里,断点指向一些系统库代码。
原因:最常见的两个,一是rankOf收到越界值,比如拿到了 54;二是CardWidget指针没有初始化就放进布局。
解决:在rankOf开头加断言:
Q_ASSERT_X(card >= 0 && card < 54, "rankOf", "card value out of range");调试模式下断言失败会直接定位到调用处。另外创建牌控件时统一用new CardWidget(deck[i], this),不要把局部变量CardWidget w(...)的地址传出去,那会在函数结束时变成悬空指针。
5.2 Windows 下中文变乱码
现象:按钮文字显示成“鍑虹墝”之类的乱码。
原因:Windows 上 Qt Creator 默认源码编码和编译器读取编码不一致,尤其老版本 Qt 5.14.2 配合 MSVC 时常见。字符串字面量 “出牌”在源码里存成 UTF-8,MSVC 默认按本地代码页 GBK 读,于是解码错位。
解决:统一源码编码为 UTF-8,并在文件头部加#pragma execution_character_set("utf-8")(仅 MSVC 可用)。更稳妥的方式是用QStringLiteral("出牌")宏,它确保字符串在编译期以 UTF-16 存储,不依赖源码编码。代码里所有中文 UI 文本都应该走这个宏。
5.3 每次重开起手牌一模一样
现象:关掉程序再开,三家起手牌完全一样,感觉不是随机。
原因:旧代码里用了qsrand(1)或srand(0)这种固定种子,甚至有人直接把QRandomGenerator global的种子写死。
解决:不要自己管理全局种子,直接用QRandomGenerator::global()->generate(),或者用std::shuffle传*QRandomGenerator::global()。如果要复现某一局用于调试,可以先用固定的std::mt19937生成一次,日志里把种子记下来,第二天回放时再用同一个种子。
5.4 顺子判断把 2 也算进去
现象:手牌 10、J、Q、K、A 能出,但 9、10、J、Q、K、2 也被当成合法顺子。
原因:连续区间检测只检查了数量都等于 1,没限制区间终点必须在 rank 12 以下。这个坑我在第 3 章已经踩过,解决方式是统一加end < 12判断。
顺带一提,A 的 rank 是 11,10 的 rank 是 7,所以 10JQKA 对应的 rank 区间是 7 到 11,合法;23456 里 2 的 rank 是 12,被end < 12拦下。写测试用例时一定要把 “10JQKA 合法”和“23456 不合法”两条都加进去。
5.5 点“不出”却一直卡死,界面失去响应
现象:轮到玩家操作时点“不出”没反应,鼠标变转圈。
原因:在槽函数里写了循环等待,或者while (!condition)自旋等待某个标志位,而标志位只能由 UI 事件改变,于是死锁。
解决:任何等待都要交给事件循环。比如等 AI 出牌,用QTimer::singleShot,不要写while(m_aiThinking) { QCoreApplication::processEvents(); }。processEvents在嵌套调用时容易让同一信号被触发两次,造成重复出牌,排查起来非常痛苦。
5.6 图形界面卡顿,点牌有延迟
现象:窗口能打开,但点一张牌要顿一下才浮起来。
原因:paintEvent里做了不必要的 I/O,比如每次重绘都去读图片文件、创建QFont和QColor对象。
解决:把字体、颜色、QPixmap 全部定义为成员变量或 static 变量,只创建一次。另外不要在主窗口的paintEvent里遍历绘制全部牌,让每个CardWidget自己画自己,Qt 会按需只重绘脏区域。
6. 验证技巧:用日志回放整局,再补两个让答辩加分的点
6.1 日志回放:把每手牌记成文本
验收一个斗地主项目,光靠肉眼点几局很难证明逻辑没问题。我习惯给整个牌局加一个日志列表:
struct GameLogEntry { int playerIndex; QVector<int> cards; }; QVector<GameLogEntry> m_log; void recordPlay(int playerIndex, const QVector<int>& cards) { m_log.append({playerIndex, cards}); }每局结束把日志写入文本文件:
QFile f("game_log.txt"); if (f.open(QIODevice::WriteOnly | QIODevice::Text)) { QTextStream out(&f); for (const auto& e : m_log) { out << "玩家" << e.playerIndex << ":"; for (int c : e.cards) out << rankText(c) << " "; out << "\n"; } }这样打 20 局后,把日志给同学或导师看,每一步都能回放,比口头说“逻辑没问题”有说服力得多。我期末检查时就靠这个日志找出了 AI 连续两轮不出牌的状态推进 bug。
6.2 两个进阶小方向:AI 记牌和本局最佳提示
如果还有余力,最推荐加“AI 记牌”功能:统计场上已经出过的牌中,大于当前 AI 最大单张的牌还剩几张。实现不复杂,维护一个bool played[54]数组,每次出牌时标记即可。注意这需要一个碗扣算法。
另一个方向是“提示”按钮增强:把“提示”从单纯找最小牌升级成“寻找能赢下这一手的牌型”,虽然只是贪心,但演示效果立刻不一样。不必追求完美胜负算法,期末大作业能完整跑完一局,裁判逻辑和界面交互都稳定,已经能拿到不错的结果。
我自己的教训是:永远先做通主流程,再做优化。曾经在洗牌动画上花了两天,结果牌型识别还没写完,最后通宵赶工查越界。这个题目的核心是发牌、裁判、回合循环三件事,先把它们跑通,再加美术和动画。希望这篇笔记能帮你在期末前少走一段弯路。
本文还有配套的精品资源,点击获取