简介:一份基于QT与C++实现的德州扑克游戏完整工程,适合C++课程设计、期末大作业及QT界面开发新手参考,麻雀虽小但功能完善,可直接编译运行。压缩包共97个文件,包含9个cpp和8个h源码文件、2个ui界面设计、1个pro工程配置,以及大量png图片素材、ico图标和doc文档,打包后仅9.63MB。项目已吸引514人学习浏览,可见其实用价值。代码中为关键模块标注了清晰注释,覆盖玩家、庄家、牌堆、AI策略等核心类,配合文档可快速掌握QT事件机制、信号槽和布局管理。界面操作直观,非常适合用来理解完整桌面应用的开发流程,也便于在此基础上二次修改完成作业。整体是一份结构清晰、开箱即用的高分课程设计范例。
1. 期末大作业选题“QT+C++德州扑克”:为什么这套代码值得你抄进课设
如果这学期要交一份QT+C++课程设计,与其在图书管理、贪吃蛇、计算器里挑来挑去,不如看看“基于QT+C++开发的德州扑克游戏源码+详细文档说明(期末大作业)”这个方向。德州扑克的规则刚好卡在大学C++课的知识范围内:洗牌要随机算法,牌型判定要组合比较,下注流程是典型状态机,界面还能把QT的信号槽、自绘控件、布局管理全部用上,一套代码覆盖五个以上考核点。
这里按我自己的交作业路径来写:先立工程结构,再把规则层跑通,然后接界面和自绘,最后把编译踩坑和答辩准备放在一起。适合刚拿到源码不知道怎么组织的人,也适合想自己重写但怕牌型逻辑翻车的人。你不需要懂QT底层,按章节走下去就能得到一份能跑、能改、能讲清楚的设计。
2. 先把工程立住:三层目录结构、.pro配置和五个核心类
2.1 界面层/规则层/数据层:目录怎么摆,依赖怎么指
拿到一个QT项目的源码,第一件事不是看main.cpp,而是看目录。我习惯把工程拆成core、ui、doc三层,源码组织大致是这样:
HoldemTable/ ├── main.cpp ├── HoldemTable.pro ├── core/ │ ├── card.h / card.cpp │ ├── deck.h / deck.cpp │ ├── hand_evaluator.h / hand_evaluator.cpp │ ├── player.h / player.cpp │ └── game_table.h / game_table.cpp ├── ui/ │ ├── mainwindow.h / mainwindow.cpp │ ├── card_label.h / card_label.cpp │ └── seat_widget.h / seat_widget.cpp └── doc/ └── 期末作业说明.mdcore目录理论上只依赖QtCore,不碰任何widgets组件;ui目录只负责界面和信号转发。一个很直接的检验方式:把core目录下的.cpp单独编译成一个控制台程序,如果能通过编译,说明界面依赖没有漏进规则层。期末答辩老师问“项目核心逻辑在哪”,你就可以直接指GameTable和HandEvaluator这两个类。
新手最常见的错误是把牌型判断写在MainWindow里,最后越改越乱。界面类要刷新几十个控件,还承担着规则判断,一旦逻辑出错,你连断点都不知道打在哪。分层之后,MainWindow只做三件事:接收按钮点击、调用GameTable的接口、刷新显示。谁改了底池、谁赢了多少,这些状态全部由core层的GameTable回答。
2.2 .pro文件里的关键配置:c++17、widgets模块和/utf-8
.pro文件是qmake的入口,很多编译问题其实在.pro里就埋下了。期末项目建议直接把.pro写成下面这样:
QT += core gui widgets CONFIG += c++17 TARGET = HoldemTable TEMPLATE = app SOURCES += \ main.cpp \ core/card.cpp \ core/deck.cpp \ core/hand_evaluator.cpp \ core/player.cpp \ core/game_table.cpp \ ui/mainwindow.cpp \ ui/card_label.cpp \ ui/seat_widget.cpp HEADERS += \ core/card.h \ core/deck.h \ core/hand_evaluator.h \ core/player.h \ core/game_table.h \ ui/mainwindow.h \ ui/card_label.h \ ui/seat_widget.h msvc { QMAKE_CXXFLAGS += /utf-8 }这里解释三个关键配置。第一,widgets模块必须加,否则QWidget、QLabel这些类全部找不到。第二,c++17为的是让代码里可以用结构化绑定和if初始化语句,如果你所在实验室编译器比较老,改回c++11也能编译,代价是手动拆pair。第三,msvc块里的/utf-8是给MSVC编译器看的,防止UTF-8源码里的中文在编译时按GBK解析导致乱码;如果你用MinGW,这个块不生效,应对办法是字符串统一用QStringLiteral包裹。
还有一条血泪经验:每次增删了.cpp或.h文件,一定要先在Qt Creator里执行一次qmake,再执行构建。只点“构建”不会自动重新读取.pro,后果是“头文件找不到”之类的报错,很多人以为是代码问题,其实是Makefile没更新。
2.3 五个核心类的最小设计:谁持有状态,谁只发命令
一个不夸张的最小设计只需要五个类:Card、Deck、HandEvaluator、Player、GameTable。它们的职责边界是这条线的关键。
| 类 | 职责 | 关键接口 |
|---|---|---|
| Card | 单张牌的数据结构,只存rank和suit | rank取2~14,suit取0~3 |
| Deck | 管理52张牌,提供洗牌和发牌 | shuffle()、drawCard() |
| HandEvaluator | 纯静态方法,判定5张/7张牌型并比较大小 | evaluateFive()、evaluateSeven() |
| Player | 玩家状态:姓名、筹码、手牌、是否弃牌 | fold()、addChips()、resetHand() |
| GameTable | 牌局状态机:阶段、底池、盲注、行动轮转 | advancePhase()、playerAct()、settle() |
这套设计里,GameTable持有Deck和所有Player,但本身不继承QObject,不包含任何界面控件指针。它的输入是“哪个玩家做了什么动作”,输出是“当前状态变了”,MainWindow再去拉取状态刷新界面。这样做的好处是,规则验证可以在完全脱离界面的事件循环下进行,AI模块以后也可以直接复用GameTable。
很多网上的源码会把Deck、HandEvaluator全写成MainWindow的私有方法,看起来省事,但答辩时很难自圆其说。把类拆出来,答“为什么这样设计”时,你只需要说一句话:数据、规则、界面三者隔离,规则层可独立测试。
3. 从洗牌到摊牌:德州扑克规则层的完整C++实现
3.1 洗牌与发牌:用QRandomGenerator替代rand(),避免每次启动牌序一样
洗牌这件事,教科书喜欢讲Fisher-Yates,但很多人实际写的时候还是用rand()。rand()有两个问题:不设置随机种子会导致每次启动程序牌序完全一样;它通常只提供15位随机性,洗52张牌时排列空间的覆盖度不够。QT 5.10以后提供了QRandomGenerator,底层采用系统随机源,线程安全,直接和std::shuffle配合。
Deck的构造和洗牌实现如下:
#include <QRandomGenerator> #include <algorithm> Deck::Deck() { for (int suit = 0; suit < 4; ++suit) for (int rank = 2; rank <= 14; ++rank) cards_.emplace_back(rank, suit); } void Deck::shuffle() { std::shuffle(cards_.begin(), cards_.end(), *QRandomGenerator::global()); } Card Deck::drawCard() { if (cards_.empty()) return Card(); Card c = cards_.back(); cards_.pop_back(); return c; }这里解释两个细节。第一,std::shuffle的第三个参数要求传入一个均匀随机位生成器对象,QRandomGenerator::global()返回的是指针,所以必须解引用再传。第二,Deck构造时按“先花色后点数”双重循环生成52张牌,保证每局开始时牌序可预期,洗牌后才随机。发牌时从vector尾部取牌,时间复杂度O(1)。
发牌逻辑也有讲究。在德州扑克里,每人两张底牌应该是一张一张发的,而不是先给1号玩家发两张,再给2号玩家发两张。对应代码是两层循环:
void GameTable::dealHoleCards(int playerCount) { for (int round = 0; round < 2; ++round) { for (auto& p : players_) { if (!p.folded && p.chips >= 0) p.handCards.push_back(deck_.drawCard()); } } }这个顺序在桌面上就是轮流发牌,写完后可以打印每名玩家的handCards.size()来验证,必须是每人两张且不会有人多拿。
3.2 牌型判定与比大小:C(7,5)组合枚举,规避贪心判定的边界漏洞
德州扑克规则层最核心的部分是牌型判定。常见做法是从7张牌(2张手牌+5张公共牌)里选出最大的5张牌组成牌型。我选择直接用组合枚举:C(7,5)等于21,把21种5张组合全部算一遍,取最大的HandValue。21次判定的耗时在微秒级,完全不影响界面流畅度,而且不用写各种贪心规则去处理“既是同花又是顺子”的边界。
先定义一个HandValue结构体,它不只是一个整数等级,还带一组用于比较的kickers:
struct HandValue { int rank = 0; // 0高牌 1一对 2两对 3三条 4顺子 5同花 6葫芦 7四条 8同花顺 std::vector<int> kickers; // 从大到小的比较值 bool operator>(const HandValue& other) const { if (rank != other.rank) return rank > other.rank; return std::lexicographical_compare( other.kickers.begin(), other.kickers.end(), kickers.begin(), kickers.end()); } };rank决定牌型等级,kickers存关键牌的点数。比如一对时kickers是{对子点数, 剩余三张踢脚},两对时是{高对点数, 低对点数, 踢脚}。比较时先比rank,再逐个比kickers,正好对应“先比牌型,再比牌面大小”的规则。
5张牌的具体判定函数可以这样写:
#include <algorithm> #include <cassert> HandValue evaluateFive(const std::vector<Card>& cards) { assert(cards.size() == 5); std::vector<int> ranks; for (const auto& c : cards) ranks.push_back(c.rank); std::sort(ranks.rbegin(), ranks.rend()); bool flush = true; for (int i = 1; i < 5; ++i) if (cards[i].suit != cards[0].suit) flush = false; std::vector<int> uniq; for (int r : ranks) if (uniq.empty() || uniq.back() != r) uniq.push_back(r); bool straight = (uniq.size() == 5 && uniq.front() - uniq.back() == 4); if (uniq.size() == 5 && uniq[0] == 14 && uniq[1] == 5 && uniq[2] == 4 && uniq[3] == 3 && uniq[4] == 2) { straight = true; ranks = {5, 4, 3, 2, 1}; // A2345 按 5 高顺子比较 } std::vector<std::pair<int, int>> cnt; for (int r : ranks) { if (cnt.empty() || cnt.back().first != r) cnt.push_back({r, 1}); else cnt.back().second++; } std::sort(cnt.begin(), cnt.end(), [](const auto& a, const auto& b) { if (a.second != b.second) return a.second > b.second; return a.first > b.first; }); HandValue hv; if (flush && straight) hv.rank = 8; else if (cnt[0].second == 4) hv.rank = 7; else if (cnt[0].second == 3 && cnt[1].second == 2) hv.rank = 6; else if (flush) hv.rank = 5; else if (straight) hv.rank = 4; else if (cnt[0].second == 3) hv.rank = 3; else if (cnt[0].second == 2 && cnt[1].second == 2) hv.rank = 2; else if (cnt[0].second == 2) hv.rank = 1; else hv.rank = 0; for (const auto& p : cnt) hv.kickers.push_back(p.first); return hv; }这段代码有几个容易写错的点。第一,cnt排序时先按出现次数降序,再按牌值降序,这样kickers天然是“先关键牌大小,再踢脚”的顺序。第二,A2345这种特殊顺子,A要降级成1,所以判断命中后直接把ranks重置为{5,4,3,2,1},而不是用{14,5,4,3,2}参与比较,否则它会比KQJ10 9还大,完全反了。
7张牌取最大牌型,用组合数枚举:
HandValue evaluateSeven(const std::vector<Card>& seven) { assert(seven.size() == 7); HandValue best; std::vector<int> idx = {0, 1, 2, 3, 4}; while (true) { std::vector<Card> five = { seven[idx[0]], seven[idx[1]], seven[idx[2]], seven[idx[3]], seven[idx[4]] }; HandValue hv = evaluateFive(five); if (hv > best) best = hv; int pos = 4; while (pos >= 0 && idx[pos] == 7 - 5 + pos) --pos; if (pos < 0) break; ++idx[pos]; for (int j = pos + 1; j < 5; ++j) idx[j] = idx[j - 1] + 1; } return best; }枚举下标的递增是标准组合数生成逻辑,这里不做剪枝,因为21次调用evaluateFive的开销可以忽略。之所以不用贪心判定,是因为“7张里选5张”存在很多交叉情况,比如同时有顺子和同花但未必是同花顺,贪心容易漏。组合枚举的优点是逻辑简单,肉眼可验证,答辩时也容易讲。
牌型等级表建议直接写进文档,方便答辩时展示:
| rank | 牌型 | 说明 |
|---|---|---|
| 8 | 同花顺 | 例如 A♠ K♠ Q♠ J♠ 10♠ |
| 7 | 四条 | 例如 8♣ 8♦ 8♥ 8♠ A♥ |
| 6 | 葫芦 | 三条加一对 |
| 5 | 同花 | 五张同花色但不成顺 |
| 4 | 顺子 | 五张连续但花色不同 |
| 3 | 三条 | 三张同点加两张散牌 |
| 2 | 两对 | 两个对子加一张踢脚 |
| 1 | 一对 | 一个对子加三张踢脚 |
| 0 | 高牌 | 什么都不沾,比最大单张 |
3.3 下注轮转的状态机:翻牌前、翻牌、转牌、河牌的流转条件
德州扑克整局的核心状态流转是:翻牌前(PreFlop) -> 翻牌(Flop) -> 转牌(Turn) -> 河牌(River) -> 摊牌(Showdown)。用枚举加switch实现:
enum class Phase { PreFlop, Flop, Turn, River, Showdown }; void GameTable::advancePhase() { switch (phase_) { case Phase::PreFlop: burnCard(); dealCommunity(3); phase_ = Phase::Flop; break; case Phase::Flop: burnCard(); dealCommunity(1); phase_ = Phase::Turn; break; case Phase::Turn: burnCard(); dealCommunity(1); phase_ = Phase::River; break; case Phase::River: phase_ = Phase::Showdown; break; case Phase::Showdown: settle(); break; } }这里的burnCard()是烧牌:从牌堆抽一张直接弃掉,不放进公共牌。规则上这是为了防止有人通过上一轮操作顺序推测下一张牌,程序里更多是为了模拟真实牌桌仪式感,但很多源码会漏掉这一步导致公共牌数量对不上,所以要保留。
advancePhase()不能由界面按钮直接乱调,必须先确认当前下注轮次已经结束。下注轮转的判断逻辑是核心:
bool GameTable::bettingRoundFinished() const { int baseCall = currentCallAmount_; for (auto& p : players_) { if (p.folded || p.chips == 0) continue; if (p.lastBet != baseCall) return false; } return true; } void GameTable::nextTurn() { do { currentIdx_ = (currentIdx_ + 1) % players_.size(); } while (players_[currentIdx_].folded || players_[currentIdx_].chips == 0); }下注轮转的边界条件比想象中多。弃牌玩家跳过,筹码为0的全押玩家跳过,只剩下一个玩家时牌局必须提前结束。另外,多数实现会记录lastAggressorIndex,也就是最近一次加注的人,轮到它时这一轮才结束;不然只会陷入无限循环。这个字段在期末文档里建议单独写清楚。
3.4 摊牌结算与底池分配:先比牌型,再算筹码,最后处理全押边界
摊牌结算时,先把未弃牌的玩家挑出来,每人用自己的2张手牌加5张公共牌组成7张,调用evaluateSeven取最大值,然后找出最大值对应的玩家:
void GameTable::settle() { std::vector<Player*> showdownPlayers; for (auto& p : players_) if (!p.folded) showdownPlayers.push_back(&p); if (showdownPlayers.empty()) return; Player* winner = showdownPlayers.front(); HandValue bestValue = evaluateSeven(combine7(winner->handCards)); for (Player* p : showdownPlayers) { HandValue hv = evaluateSeven(combine7(p->handCards)); if (hv > bestValue) { bestValue = hv; winner = p; } } winner->chips += pot_; pot_ = 0; }combine7是一个辅助函数,把手牌和公共牌拼成一个7张vector,这里省略展开。需要注意,如果出现完全相同的牌型,按德州扑克规则应该平分底池。期末项目里可以做成“双赢平分”,也可以做成“按座位顺序先到先得”,但后者建议在文档里注明是简化处理。
全押是另一个边界黑洞。当玩家A投入100而玩家B只投入50时,A不能赢走B没投入的部分。完整实现需要边池(side pot)计算,对期末作业来说复杂度明显上升。一个诚实的做法是:界面端限制同时只有一人可全押,或者在文档中写明“当前版本只支持单底池,多人全押按总底池平分”。答辩时坦白说明这个取舍,远比隐藏bug后被老师试出来要好。
4. 把牌桌画出来:QT Widgets界面层与自绘扑克牌
4.1 主窗口的信号槽链路:按钮只发命令,规则层不碰界面
界面层第一原则:按钮点击不直接修改牌局数据,而是通过GameTable的接口执行动作,然后整体刷新UI。这个链路一旦建立,后面的逻辑会非常干净。
主窗口里三个动作按钮的连接可以写成:
connect(btnFold, &QPushButton::clicked, this, [=] { gameTable_.playerAct(currentPlayerIndex(), PlayerAction::Fold); refreshUi(); }); connect(btnCall, &QPushButton::clicked, this, [=] { gameTable_.playerAct(currentPlayerIndex(), PlayerAction::Call); refreshUi(); }); connect(btnRaise, &QPushButton::clicked, this, [=] { gameTable_.playerAct(currentPlayerIndex(), PlayerAction::Raise, raiseSpinBox->value()); refreshUi(); });新式connect语法在这里有两个好处:信号和槽参数不匹配会在编译期报错,lambda里可以顺手刷新界面。如果你在网上的老代码里看到connect(btn, SIGNAL(clicked()), this, SLOT(onClick()))这种写法,字符串一旦拼错,运行期才提示“No such slot”,期末阶段很容易排查半天。
refreshUi()的典型实现是从gameTable_拉取玩家列表、底池、公共牌,然后逐个更新控件内容。它不包含任何规则判断,只做显示同步。这样即使界面显示有bug,也不会污染牌局数据。
4.2 CardLabel自绘控件:用QPainter画牌面和牌背,不依赖图片素材
期末大作业如果去找扑克牌图片素材,会遇到分辨率不匹配、背景色不统一、素材包里缺牌等各种问题。更省事且答辩更讨喜的做法是自绘控件。继承QWidget重写paintEvent,用QPainter画圆角矩形和花色文字:
class CardLabel : public QWidget { Q_OBJECT public: void setCard(const Card& c) { card_ = c; hasCard_ = true; update(); } void setFaceDown() { hasCard_ = false; update(); } protected: void paintEvent(QPaintEvent*) override { QPainter p(this); p.setRenderHint(QPainter::Antialiasing); if (!hasCard_) { p.setBrush(QColor(30, 30, 60)); p.drawRoundedRect(rect(), 8, 8); return; } p.setBrush(Qt::white); p.drawRoundedRect(rect(), 8, 8); QString text = rankText() + suitText(); QColor color = (card_.suit == 1 || card_.suit == 2) ? Qt::red : Qt::black; p.setPen(color); QFont font; font.setFamily(QStringLiteral("Segoe UI Symbol")); font.setPixelSize(20); p.setFont(font); p.drawText(rect(), Qt::AlignCenter, text); } private: Card card_; bool hasCard_ = false; };paintEvent会在调用update()时被触发,所以setCard之后调用update()即可重绘。setRenderHint(QPainter::Antialiasing)是抗锯齿开关,不设置的话圆角矩形边缘会出现明显锯齿,影响观感。花色颜色判断要注意:suit默认0黑桃、1红心、2方块、3梅花,所以红心方块取红色,黑桃梅花取黑色。
这里不贴图片素材的另一个原因是可以现场调参。答辩时老师如果问“花色怎么画出来的”,直接改一行颜色代码重新编译演示,比解释素材来源有说服力得多。
4.3 复用玩家座位:一个QFrame加两个QLabel完成信息展示
六个玩家座位如果每个都手动写一遍布局,代码会膨胀到没法看。更合理的方式是做一个SeatWidget,内部固定结构:头像区、两张手牌、筹码数。
SeatWidget::SeatWidget(const QString& name, QWidget* parent) : QWidget(parent) { auto* layout = new QVBoxLayout(this); nameLabel_ = new QLabel(name, this); chipsLabel_ = new QLabel(QStringLiteral("筹码:1000"), this); card1_ = new CardLabel(this); card2_ = new CardLabel(this); layout->addWidget(nameLabel_); layout->addWidget(card1_); layout->addWidget(card2_); layout->addWidget(chipsLabel_); setFixedSize(120, 180); }界面布局上,公共牌区域放在桌面中央,六名玩家座位按下标围成一圈。MainWindow里用QGridLayout把SeatWidget摆到对应位置。当前轮到的玩家座位可以用样式表高亮边框:
seatWidget->setStyleSheet( QStringLiteral("QWidget#activeSeat { border: 2px solid #ffcc00; }"));注意样式表设置后要确保SeatWidget的objectName正确,否则边框不生效。setFixedSize防止布局在筹码位数变化时跳动,这个细节虽然小,但演示时筹码从999变成1000会导致整个桌面抖一下,很掉价。
5. 编译与运行避坑:Qt 5.15.2、MSVC与中文路径的5条血泪经验
这一章的背景环境是Qt 5.15.2配MSVC2019_64加Qt Creator,这也是期末机器上最常见的组合。整套环境如果一路绿灯只需要十几分钟,但翻车点集中在路径、编码、信号槽三件事上。下面每条按“现象 -> 原因 -> 解决”来说。
5.1 报错“:-1: error: dependent ... qtwidget”时,先查Kit路径而不是重装
现象:打开.pro后第一次构建,编译器直接弹出一行超长路径错误,报错信息里出现类似“dependent '....\qt\5.15.2\msvc2019_64\include\qtwidget' does not exist”的提示,整个项目的头文件全部飘红。
原因:Qt Creator的构建套件(Kit)里,Qt Version路径没有正确指向实际安装位置。常见触发场景是从其他机器拷贝了.pro或整个工程目录,而工程依赖的绝对路径还残留着别人的盘符;或者手动修过QTDIR环境变量,指向了一个不存在的include目录。
解决:打开Qt Creator的“工具 -> 选项 -> Kits -> Qt Versions”,检查当前选中的qmake路径是否真实存在。确认后重新执行qmake,再重新构建。不要一上来就重装Qt,这个报错九成是路径配置问题,不是安装损坏。
5.2 QString中文乱码:源码编码、MSVC和/utf-8之间的三角关系
现象:按钮文字、日志输出里的中文变成“锟斤拷”或一堆问号,英文和数字正常。
原因:VS系列编译器默认按本地代码页GBK解释窄字符字面量,而大多数情况下源码文件保存为UTF-8。Qt 5中QString从const char*构造时会按本地编码解码,两边的解释不一致,中文就变成乱码。Qt 5.15本身是支持UTF-8源码的,但需要追加编译选项。
解决:在.pro文件里加一行,让MSVC按UTF-8解析源码:
msvc { QMAKE_CXXFLAGS += /utf-8 }如果是MinGW工具链,msvc块不生效,需要把中文字符串统一写成QStringLiteral,或者把源码转成带BOM的UTF-8。期末阶段最保险的做法是两者同时做:.pro加/utf-8,代码里所有中文字符串用QStringLiteral包裹。这样即使换机器,出现乱码的概率也会大幅下降。
5.3 按钮点击无反应:新式connect与带默认参数信号的选择
现象:编译通过,界面也正常启动,但点击“跟注”“加注”按钮后,界面毫无变化,日志窗口也没有任何输出。
原因:这通常不是信号没触发,而是连接方式有隐患。老式SIGNAL/SLOT宏里,信号或槽函数名写错一个字符,编译期不报错,运行期只在输出窗口打印“No such signal/slot”。很多人根本不看Qt Creator的“输出”面板,于是表现为按钮无反应。另一种常见情况是按钮被setEnabled(false)之后忘了恢复,界面处于灰化状态。
解决:统一使用新式语法连接信号和lambda,不要在期末代码里混用字符串宏。连接后用qDebug加一行输出确认触发链路是通的。
connect(btnCall, &QPushButton::clicked, this, [=] { qDebug() << QStringLiteral("clicked call"); gameTable_.playerAct(currentPlayerIndex(), PlayerAction::Call); refreshUi(); });顺带提一个隐蔽点:QPushButton::clicked信号带一个bool参数,如果你的lambda不接收参数,新式connect也能正常连接;但如果槽函数是一个无参成员函数,就需要注意编译期对参数数量的检查规则。出现不确定时,优先用lambda壳包一层最省事。
5.4 高分屏界面发虚:QApplication创建前打开High-DPI属性
现象:在高分屏笔记本上运行,整个界面字体和控件边缘发虚,按钮文字有毛边,窗口尺寸偏小。
原因:Qt 5默认不自动启用高分屏缩放,系统DPI超过100%时,界面按物理像素绘制,最终被系统拉伸,自然就糊了。Qt 5.15.2需要手动开启。
解决:在main函数里,创建QApplication之前设置属性:
#include <QApplication> int main(int argc, char *argv[]) { QApplication::setAttribute(Qt::AA_EnableHighDpiScaling); QApplication app(argc, argv); MainWindow w; w.show(); return app.exec(); }注意setAttribute必须发生在QApplication对象构造之前,否则不会生效。如果以后把项目切到Qt 6,这行可以不写,因为Qt 6默认开启高DPI缩放。
5.5 Unicode花色显示成方框:字体缺失不是程序错误
现象:界面上的花色字符显示成一个个小方框或问号,其他文字正常。
原因:当前控件所用的字体不包含♠♥♦♣这些Unicode字符的glyph。Windows上默认字体有时会缺字符,尤其是精简版系统或远程桌面环境。
解决:在绘制时显式指定一个包含扑克牌花色的字体,比如Windows自带的Segoe UI Symbol:
QFont font; font.setFamily(QStringLiteral("Segoe UI Symbol")); font.setPixelSize(20);如果机器连这个字体都没有,就不要和字体死磕,直接把花色映射成S/H/D/C字母,或者自己用QPainter画实心圆和尖角图标。功能不受影响,答辩时也不会因为字体问题翻车。Linux环境下可以尝试Noto Sans Symbols,但期末项目建议优先保证Windows上能跑通。
6. 验收、自测与答辩:让期末项目经得起追问
6.1 手动测试用例表与日志开关
交作业之前,至少把下面这张手工测试表跑一遍,并把结果写进文档里:
| 场景 | 操作 | 期望结果 |
|---|---|---|
| 弃牌 | 翻牌前点击弃牌 | 该玩家手牌隐藏,不再参与下注轮转 |
| 跟注 | 前一玩家加注100后跟注 | 当前玩家lastBet等于100,底池增加100 |
| 加注 | 点击加注并输入200 | 当前玩家lastBet变为200,后续玩家需跟注200 |
| 全押 | 筹码不足跟注额时点跟注 | 玩家筹码清零,后续轮转跳过该玩家 |
| 摊牌 | 河牌后自动结算 | 底池归入最高牌型玩家,界面弹出赢家提示 |
为了排查方便,可以在GameTable里加一个日志开关,只在调试构建打印:
#ifdef QT_DEBUG qDebug() << QStringLiteral("[牌局] %1 行动,底池 %2") .arg(playerName).arg(pot_); #endif发布构建时QT_DEBUG未定义,这些日志自动剔除,不用手动删。
6.2 不依赖GTest的回归测试:assert + 一个main入口跑完核心规则
用Google Test搭测试框架对期末项目反而增加负担。直接用assert写一个回归测试函数,挂在单独的控制台main入口里:
#include <cassert> void testHandEvaluator() { std::vector<Card> royal = { Card(14, 1), Card(13, 1), Card(12, 1), Card(11, 1), Card(10, 1) }; assert(evaluateFive(royal).rank == 8); std::vector<Card> lowStraight = { Card(14, 0), Card(5, 0), Card(4, 0), Card(3, 0), Card(2, 0) }; assert(evaluateFive(lowStraight).rank == 4); std::vector<Card> fullHouse = { Card(10, 0), Card(10, 1), Card(10, 2), Card(4, 0), Card(4, 1) }; std::vector<Card> flush = { Card(2, 3), Card(5, 3), Card(8, 3), Card(11, 3), Card(13, 3) }; assert(evaluateFive(fullHouse) > evaluateFive(flush)); // A2345是最小顺子,应小于6-high顺子 }这个测试的巧妙之处在于它同时验证了三件事:皇家同花顺判定为8、A2345被识别为顺子、葫芦大于同花。跑测试时用Debug配置,assert才会生效;一旦某个断言失败,程序会直接中断并报告行号,问题范围锁定非常快。
6.3 答辩加分点:AI跟注决策、Monte Carlo胜率模拟、QML重构方向
如果想让项目在同班同学里脱颖而出,三个方向可以任选一个做深。第一是给电脑玩家加一个简单AI,不写复杂策略,只用期望值判断:模拟胜率乘以当前底池,如果大于跟注所需筹码就跟,否则弃牌。第二是在界面显示当前手牌胜率,做法是Monte Carlo模拟,随机补全对手手牌和剩余公共牌,两万次后统计胜率。第三是QML重构方向,把Widgets界面替换成QML,规则层GameTable用Q_INVOKABLE暴露给前端。答辩时说到“规则层和界面层彻底解耦,将来换QML界面不需要动C++逻辑”这句话,老师通常会追问细节,答出来就是亮点。
我交期末作业前,最怕的不是程序跑不起来,而是老师问一个边角规则我说不清。后来养成的习惯是:每写一条规则,就在测试代码里留一条断言;每换一次编译器,先跑一遍core层测试再开界面。这个习惯帮我避开了很多隐蔽问题,希望也能帮到你。
本文还有配套的精品资源,点击获取