Qt可视化21点游戏课设:状态机与信号槽实战解析

发布时间:2026/10/13 8:00:41
Qt可视化21点游戏课设:状态机与信号槽实战解析
简介这是一份基于Qt 5.12开发的21点纸牌游戏课程设计工程面向C与Qt初学者也适合图形界面课设参考。资源共86个文件压缩包2.2MB其中58张png图片用于扑克牌面与背景10个cpp源文件与8个头文件构成主要程序逻辑另有2个ui界面设计文件、qrc资源文件、pro工程配置和一个python辅助脚本覆盖界面布局、信号槽事件、随机发牌、点数计算与爆牌判断。已有1471人学习下载常用于课程设计与入门练手。项目涉及常用Qt控件、布局管理、信号与槽机制以及牌堆数据结构、洗牌随机数、点数计算和规则判断等算法均可在源码中对照学习。代码目录按图片、界面、源码和资源分离结构清晰便于逐模块阅读和后续扩展比如加入加倍、拆牌或联机对战整体是一份可直接运行、适合期末课设或Blackjack规则演示的实用案例。1. 拿到这个课设标题先想清楚它到底在考什么拿到“Qt可视化界面版21点游戏课设”这个题目时我第一反应不是去翻 21 点规则而是先问了一句这个课设到底在验收什么它不是一道纯算法题也不是界面摆件题。它的核心是让程序在一个事件驱动的框架里把“牌局状态”这一件事走通发牌、要牌、停牌、庄家补牌、判断胜负每一步都要让界面及时反映出来。这才是 Qt 游戏课设真正想看到的交付物。这个方向能解决两类实际问题一类是刚接触 Qt 的同学需要一条不靠贴图、不靠现成 Demo 就能自己写出来的路径另一类是已经能写简单控件的同学想知道怎么把规则、状态和界面分层避免答辩时被一问“如果这里连点三下会怎样”就翻车。后面所有内容都按这个思路展开代码可以直接照搬也能看清楚哪些参数值得自己改。2. 先定规则边界再画界面把21点拆成能交付的模块2.1 先砍规则子集不是所有玩法都要写进课设21点是个规则很成熟的纸牌游戏但课设版不需要把赌场玩法全搬进来。我一般会先把必须做的规则列成一张清单牌面 A 算 1 或 11J/Q/K 算 10其他按牌面算玩家和庄家各两张起手牌其中庄家第一张是暗牌玩家可以要牌或停牌超过 21 点直接算爆牌庄家在自己点数小于 17 时持续要牌达到 17 或以上就停最后比点数点数大且未爆的一方获胜相等则平局。要不要做保险、分牌、加倍下注我的建议是课设阶段全砍掉。砍掉不是偷懒而是让状态机保持在“四五个状态能讲清楚”的规模。保险和分牌会引入额外分支答辩时你很难在三分钟内把规则边界讲明白。至于下注筹码建议只做比分统计不做真实货币语义既避免规则复杂化也符合课程演示的定位。还有两个细节最好提前定死一是“Blackjack黑杰克”只指起手两张牌点数和为 21三张或更多牌凑出的 21 不算二是庄家用的是“硬 17 停”也就是不管有没有 A只要算出来的最终点数达到 17 就停。这两条看似小实际会影响后面算分函数的写法。2.2 数据模型Card、Deck、Hand 三个类就够很多同学一上来就在 MainWindow 里塞 QVector 用字符串“红桃K”“黑桃A”表示牌。这样 demo 能跑但算分、洗牌、翻牌都会变得很别扭。我习惯先写三个小类Card 表示一张牌Deck 表示一副牌Hand 表示一个人手里的牌。界面层只读它们不直接改它们。// Card.h #pragma once struct Card { enum Suit { Spade, Heart, Diamond, Club }; Suit suit; int rank; // 1 是 A2~10 按面值11/12/13 是 J/Q/K bool hidden false; // 只用于界面绘制不代表真实牌值 };// Deck.h #pragma once #include Card.h #include QVector #include QRandomGenerator #include algorithm class Deck { public: void reset() { cards_.clear(); for (int s Card::Spade; s Card::Club; s) { for (int r 1; r 13; r) { cards_.append({static_castCard::Suit(s), r, false}); } } // 使用 Qt 提供的随机数生成器作为 shuffle 的随机源 std::shuffle(cards_.begin(), cards_.end(), QRandomGenerator::global()); } Card draw() { return cards_.takeLast(); // 从牌堆尾部抽牌避免头部搬移 } int remaining() const { return cards_.size(); } private: QVectorCard cards_; };这里最重要的是reset()里的洗牌方式。std::shuffle需要第三个参数作为随机源直接用QRandomGenerator::global()就是 Qt 推荐的做法它的随机种子来自系统不需要你手动初始化。takeLast()是 QVector 的尾部弹出复杂度低换来换去没有必要的开销。Hand 类的设计更简单但有个关键点它要自己会算分也要能判断黑杰克这样 GameModel 里就不用到处写循环数牌。// Hand.h #pragma once #include Card.h #include QVector class Hand { public: void addCard(const Card c) { cards_.append(c); } void clear() { cards_.clear(); } const QVectorCard cards() const { return cards_; } QVectorCard cards() { return cards_; } int points() const; // 具体算分放第 4 章这里先声明 bool isBlackjack() const { return cards_.size() 2 points() 21; } void revealAll() { for (Card c : cards_) c.hidden false; } private: QVectorCard cards_; };把isBlackjack()放进 Hand 而不是 GameModel是为了让“两张牌 21 点”这个结论只在一个地方维护。以后你想扩展“庄家黑杰克打平”或者“黑杰克 1.5 倍计分”都不需要到处改。2.3 控制模型与信号槽界面只看结果不参与规则有了 Card、Deck、Hand接下来要做的就是一个 GameModel 类它作为整局的“裁判”。界面只调三个动作开始新局、要牌、停牌界面只监听两个信号手牌变化、状态变化。这样做的好处是按钮连点、快捷键触发、后续加动画都不会让规则逻辑散落到各个控件里。// GameModel.h #pragma once #include QObject #include Deck.h #include Hand.h class GameModel : public QObject { Q_OBJECT public: enum Phase { Idle, PlayerTurn, DealerTurn, RoundEnd }; Q_ENUM(Phase) explicit GameModel(QObject *parent nullptr); const QVectorCard playerCards() const { return player_.cards(); } const QVectorCard dealerCards() const { return dealer_.cards(); } int playerPoints() const { return player_.points(); } int dealerPoints() const { return dealer_.points(); } Phase phase() const { return phase_; } signals: void handChanged(); // 任何手牌增删、翻牌都要发 void phaseChanged(GameModel::Phase phase); // 状态切换时发 public slots: void startRound(); void playerHit(); void playerStand(); private: void dealerStep(); // 庄家逐张补牌的内部步骤 void finishRound(); // 结算胜负 Deck deck_; Hand player_; Hand dealer_; Phase phase_ Idle; };信号槽在这里的作用是“解耦”。模型不 include 任何界面头文件它只负责发信号TableWidget 收到handChanged后调用update()重绘MainWindow 收到phaseChanged后决定按钮是否可用。这种写法答辩时最好讲界面是投影模型是真相界面永远不要直接改模型内部状态。3. 用 Qt Widgets 把牌桌搭起来选型理由与最小骨架3.1 为什么选 QWidget QPainter而不是 QMLQt 做界面有两条主流路线Widgets 和 QML。QML 的动画和样式确实漂亮但课设答辩的常见场景是老师指着屏幕问“这张牌为什么能移动”“动画是不是卡在另个线程”。如果你用的是 QML很多效果是框架替你完成的解释不清时反而显得像黑匣子。Widgets QPainter 则相反每一张牌都是drawRoundedRect和drawText画出来的你可以从坐标、颜色、刷新时机一路讲到底。另一个现实原因是资源文件。用图片做牌面你得处理路径、打包、缩放还有图片来源的顾虑。用 QPainter 画矩形牌面代码量多不了多少却让整个项目变成“一个 cpp 就能跑”的形态换机器、交作业都方便。QWidget 在普通卡片、几个按钮的规模下性能完全够用不需要为流畅度付出额外复杂度。对比项QWidget QPainterQML动画效果要自己写但可解释内置效果好但难讲深资源依赖几乎为零通常需要 qrc 和图片事件处理信号槽直接课设好讲绑定方式多容易绕答辩友好度高中取决于是否懂原理3.2 最小主窗口自绘牌桌控件界面层我习惯写一个TableWidget它只管画牌桌不管按钮。这样主窗口可以任意换布局牌桌不用跟着改。// TableWidget.h #pragma once #include QWidget #include GameModel.h class TableWidget : public QWidget { Q_OBJECT public: explicit TableWidget(GameModel *model, QWidget *parent nullptr); protected: void paintEvent(QPaintEvent *event) override; private: void drawCard(QPainter p, int x, int y, const Card card); GameModel *model_ nullptr; };// TableWidget.cpp #include TableWidget.h #include QPainter TableWidget::TableWidget(GameModel *model, QWidget *parent) : QWidget(parent), model_(model) { setMinimumSize(1000, 520); // 模型一变界面就重新绘制不要把 update() 漏掉 connect(model_, GameModel::handChanged, this, qOverload(QWidget::update)); } void TableWidget::paintEvent(QPaintEvent *) { if (!model_) return; QPainter p(this); p.setRenderHint(QPainter::Antialiasing); p.fillRect(rect(), QColor(34, 94, 62)); QFont f p.font(); f.setPointSize(12); p.setFont(f); p.setPen(Qt::white); p.drawText(30, 40, QStringLiteral(庄家)); p.drawText(30, 300, QStringLiteral(玩家你)); const auto dealer model_-dealerCards(); int x 30; for (const Card c : dealer) { drawCard(p, x, 90, c); x 100; } const auto player model_-playerCards(); x 30; for (const Card c : player) { drawCard(p, x, 340, c); x 100; } p.drawText(30, 500, QStringLiteral(你的点数%1 庄家点数%2) .arg(model_-playerPoints()) .arg(model_-dealerPoints())); } void TableWidget::drawCard(QPainter p, int x, int y, const Card c) { QRect r(x, y, 80, 110); p.setPen(QPen(Qt::black, 2)); if (c.hidden) { p.setBrush(QColor(40, 40, 40)); p.drawRoundedRect(r, 8, 8); p.drawText(r, Qt::AlignCenter, QStringLiteral(?)); return; } p.setBrush(Qt::white); p.drawRoundedRect(r, 8, 8); bool red (c.suit Card::Heart || c.suit Card::Diamond); p.setPen(red ? QColor(200, 20, 20) : Qt::black); QString rank; if (c.rank 1) rank QStringLiteral(A); else if (c.rank 11) rank QStringLiteral(J); else if (c.rank 12) rank QStringLiteral(Q); else if (c.rank 13) rank QStringLiteral(K); else rank QString::number(c.rank); QFont f p.font(); f.setPointSize(18); f.setBold(true); p.setFont(f); p.drawText(r.adjusted(10, 8, -10, -8), Qt::AlignTop | Qt::AlignLeft, rank); QString suitText; switch (c.suit) { case Card::Spade: suitText QStringLiteral(♠); break; case Card::Heart: suitText QStringLiteral(♥); break; case Card::Diamond: suitText QStringLiteral(♦); break; case Card::Club: suitText QStringLiteral(♣); break; } p.drawText(r.adjusted(0, 0, -12, -12), Qt::AlignBottom | Qt::AlignRight, suitText); }connect(model_, GameModel::handChanged, this, qOverload(QWidget::update));这行值得多说一句。QWidget 有好几个重载的update不包一层qOverload编译器可能分不清所以我在这里明确指定了“无参数的 update()”。update()不会立刻重绘它只是给窗口系统登记一个重绘请求批量处理免去每一张牌触发一次绘制的浪费。绘制参数里牌宽 80、高 110横向间距 100起始 x 为 30。这些数字不是随意的1000 宽窗口能放下大约 9 张牌足以覆盖大多数对局。如果牌数超过 9说明玩家或庄家已经抽了很多牌这种情况在爆牌后马上结束不会真的画到屏幕外。想要更紧凑的牌桌可以同时改牌宽、间距和setMinimumSize三个地方。3.3 控制按钮与状态联动最小交互闭环牌桌画好后要接上三个按钮要牌、停牌、重新开局。按钮不放牌桌里放主窗口底部布局更稳。// MainWindow.cpp 的构造函数核心片段 auto *table new TableWidget(model, this); auto *hitBtn new QPushButton(QStringLiteral(要牌), this); auto *standBtn new QPushButton(QStringLiteral(停牌), this); auto *restartBtn new QPushButton(QStringLiteral(重新开局), this); auto *layout new QVBoxLayout; layout-addWidget(table); auto *btnLayout new QHBoxLayout; btnLayout-addWidget(hitBtn); btnLayout-addWidget(standBtn); btnLayout-addWidget(restartBtn); layout-addLayout(btnLayout); auto *central new QWidget(this); central-setLayout(layout); setCentralWidget(central); connect(hitBtn, QPushButton::clicked, model, GameModel::playerHit); connect(standBtn, QPushButton::clicked, model, GameModel::playerStand); connect(restartBtn, QPushButton::clicked, model, GameModel::startRound); connect(model, GameModel::phaseChanged, this, [](GameModel::Phase phase) { hitBtn-setEnabled(phase GameModel::PlayerTurn); standBtn-setEnabled(phase GameModel::PlayerTurn); restartBtn-setEnabled(phase GameModel::RoundEnd); });按钮的可用状态完全由phaseChanged驱动这是一个容易忽略但很关键的细节。如果只在连接 clicked 的槽函数里判断状态界面上的按钮还会处于可点状态老师快速连点时会出现“要了一张又一张”的情况。用setEnabled把入口先关掉再用模型里的状态守卫兜底双重保险才算稳。restartBtn只在RoundEnd可用意味着对局进行中不能强行重开。有人会觉得这样不自由但课设演示需要的是“每步都有意义”中途重开反而会打断状态机也会让结算逻辑无法触发。4. 把21点规则落成回合控制状态机与庄家自动行动4.1 回合状态机四种状态与合法迁移21点桌面上看起来是“来回来去抽牌”程序里却必须是一台严格的状态机。只设四个状态就够Idle 表示还没开局PlayerTurn 表示玩家决策DealerTurn 表示庄家自动补牌RoundEnd 表示结算完成等待重新开局。当前状态允许的操作界面表现Idle开局要牌、停牌禁用重开禁用PlayerTurn要牌、停牌要牌、停牌可用DealerTurn观察要牌、停牌禁用RoundEnd重新开局重开可用显示胜负合法的迁移只有四条开局从 Idle 或 RoundEnd 到 PlayerTurn玩家点“停牌”从 PlayerTurn 到 DealerTurn玩家要牌后爆牌从 PlayerTurn 直接到 RoundEnd庄家补牌结束从 DealerTurn 到 RoundEnd。// GameModel.cpp 节选 void GameModel::startRound() { if (phase_ ! Idle phase_ ! RoundEnd) return; deck_.reset(); player_.clear(); dealer_.clear(); player_.addCard(deck_.draw()); dealer_.addCard(deck_.draw()); player_.addCard(deck_.draw()); dealer_.addCard(deck_.draw()); // 庄家第二张牌作为暗牌只在界面层隐藏 dealer_.cards().last().hidden true; phase_ PlayerTurn; emit handChanged(); emit phaseChanged(phase_); if (player_.points() 21) { // 起手黑杰克用 singleShot(0) 把停牌动作推到事件循环尾部 QTimer::singleShot(0, this, GameModel::playerStand); } } void GameModel::playerHit() { if (phase_ ! PlayerTurn) return; player_.addCard(deck_.draw()); emit handChanged(); if (player_.points() 21) { phase_ RoundEnd; dealer_.revealAll(); // 玩家爆了也要翻开暗牌让结果明朗 emit handChanged(); emit phaseChanged(phase_); } } void GameModel::playerStand() { if (phase_ ! PlayerTurn) return; phase_ DealerTurn; dealer_.revealAll(); emit handChanged(); emit phaseChanged(phase_); // 延迟 600ms 后再让庄家走第一步界面先刷出“庄家回合” QTimer::singleShot(600, this, GameModel::dealerStep); }playerHit里的第一行if (phase_ ! PlayerTurn) return;是状态守卫。按钮禁用只挡鼠标状态守卫挡一切入口包括以后可能加的快捷键、命令行调用。没有这个 if任何入口都可能打乱牌局。QTimer::singleShot(0, this, GameModel::playerStand)是一个值得记下的技巧。起手黑杰克时如果直接调用playerStand()函数栈是“startRound - playerStand - 发信号”界面还没来得及处理phaseChanged(PlayerTurn)就又收到phaseChanged(DealerTurn)。很多初学同学发现“第一局界面乱跳”就是这个原因。用 0 毫秒定时器把动作排到下一轮事件循环先把当前状态播出去再走停牌流程。庄家补牌的核心是dealerStepvoid GameModel::dealerStep() { if (phase_ ! DealerTurn) return; if (dealer_.points() 17) { dealer_.addCard(deck_.draw()); emit handChanged(); QTimer::singleShot(600, this, GameModel::dealerStep); } else { finishRound(); } }finishRound只做一件事锁定状态、翻开所有牌、广播一次。void GameModel::finishRound() { if (phase_ ! DealerTurn) return; phase_ RoundEnd; dealer_.revealAll(); emit handChanged(); emit phaseChanged(phase_); }4.2 庄家延时行动用 QTimer 而不是睡眠等待庄家自动补牌需要“肉眼可见的节奏”否则刚点击停牌牌瞬间抽完看不到过程。常见做法是QThread::sleep或者空转循环这在 UI 线程里是灾难窗口会卡死按钮点击没反应拖动窗口时直接变白。我第一版就干过这事调试时以为是程序崩了其实是主线程在睡觉。正确做法是像上面代码那样用QTimer::singleShot把每一步拆成独立事件。每次 600ms 后执行dealerStep每次只抽一张牌抽完立刻发handChanged界面重绘后再等下一个 600ms。600ms 这个参数属于“手感参数”太短像抢牌太长会让答辩现场冷场600 到 800 毫秒比较自然。如果觉得单发定时器不够直观也可以用QTimer成员变量配合setInterval(600)和start()但单发定时器更简单不用记得 stop也避免定时器泄漏。4.3 算分和黑杰克判定A 的动态调整算分是 21 点最容易写错的地方核心是 A 既可以算 1 也可以算 11。我给的策略是先按“所有 A 都算 11”累加如果总点数超过 21就把 A 降成 1每降一张扣 10直到点数不超过 21 或没有 A 可降。int Hand::points() const { int total 0; int aces 0; for (const Card c : cards_) { if (c.rank 1) { total 11; aces; } else { total std::min(c.rank, 10); } } while (total 21 aces 0) { total - 10; --aces; } return total; }std::min(c.rank, 10)这一句同时处理了 J/Q/K它们的 rank 是 11、12、13但点数都是 10。A 的处理顺序是先加 11 再统一降而不是每张 A 都现场判断这样能避免“两张 A 时先算 1 还是先算 11”的纠结。isBlackjack()的实现已经放在第 2 章 Hand 里两张牌且points() 21。三张牌组成 21 不叫黑杰克只能算普通 21这个区别在规则边界时提过现在落到函数里就是一行cards_.size() 2。胜负判断我习惯放在finishRound之后由界面读取模型只提供两个点数不直接弹窗QString GameModel::resultText() const { if (phase_ ! RoundEnd) return QString(); int p player_.points(); int d dealer_.points(); if (p 21) return QStringLiteral(你爆牌了庄家赢); if (d 21) return QStringLiteral(庄家爆牌你赢了); if (p d) return QStringLiteral(平局); return p d ? QStringLiteral(你赢了) : QStringLiteral(庄家赢); }界面只需要在phaseChanged(RoundEnd)时把这个文本显示出来规则全部收敛在模型层。5. 课设踩坑记录五个容易翻车的 Qt 界面版21点问题5.1 每局牌序都一样洗牌成了“洗了个寂寞”现象每次点“重新开局”起手牌几乎不变好像洗牌没有生效。原因用了qsrand(0)或qrand()甚至直接rand()而std::shuffle没有被喂进一个合格的随机源。解决把洗牌改成std::shuffle(cards_.begin(), cards_.end(), QRandomGenerator::global())。QRandomGenerator::global()不需要手动设种子每次调用都会从系统随机源取数。避坑点在于别再混用qsrand/qrandQt 里它们已经被标记为过时课设代码里出现会被扣印象分。5.2 按钮连点同一回合多了一张或几张牌现象快速点击“要牌”三次手牌一下多了三张甚至越过爆牌直接进入结算。原因按钮 clicked 信号连到了playerHit但playerHit里没有状态判断界面也没有按状态禁用按钮。解决第一道门是在phaseChanged里根据状态setEnabled第二道门是模型每个公开操作入口都写一句if (phase_ ! PlayerTurn) return;。两道门缺一不可因为以后加键盘快捷键时快捷键不一定经过按钮控件。5.3 模型里的牌变了界面却迟迟不更新现象点数已经 18界面还停在 16或者翻出暗牌后桌面仍有“?”。原因只改了player_或dealer_没有发handChanged信号或者错误地调用了repaint()而不是update()。解决在一切手牌增删、翻牌操作后统一emit handChanged()。还有一条纪律不要在paintEvent里改模型、不要调用延时函数paintEvent只负责读状态然后画出来。update()会把同一帧的多次绘制请求合并连续抽三五张牌不会产生闪烁。5.4 庄家自动补牌时窗口假死现象点“停牌”后窗口变白标题栏提示“未响应”过一两秒才恢复。原因在 UI 线程里用了QThread::sleep或忙等循环模拟庄家思考。解决改成QTimer::singleShot(600, this, GameModel::dealerStep)分步执行每抽一张牌都回到事件循环一次窗口才能正常重绘和响应。这段已经是血泪经验凡是“界面卡住一两秒后又恢复”的 Qt 程序十有八九是主线程阻塞而不是逻辑算得慢。5.5 暗牌显示与暗牌计算不一致现象庄家暗牌的牌背下有张小 9但界面分数已经把它算成 10或者翻开暗牌时分数保持不变直接判定错误。原因有人在发牌时把暗牌“换成了一张背面占位牌”真正的小 9 被丢掉了还有人算分时跳过hidden true的牌。解决hidden只是Card的一个显示字段牌的真实值始终保留在 Hand 里。算分函数遍历全部手牌不看hidden界面绘制时如果hidden为真就画牌背翻牌时把hidden置 false 并发一次handChanged。6. 从“能交”到“能演示”验收清单与加分交互课设版 21 点做到“能编译、能跑”只是及格线真正拉开差距的是演示时的稳定性和讲清楚的能力。我建议交之前按下面这个清单过一遍状态对不对看“开局、要牌、停牌、爆牌、重开”五步能不能在无报错的情况下连续跑十局连点测试在 PlayerTurn 时快速点“要牌”十次手牌不应异常增加暗牌规则庄家点数在计算时必须包含暗牌界面可以隐藏但逻辑不能隐藏拖拽测试把窗口缩小到最小尺寸再恢复牌桌不能出现绘制残留重开测试RoundEnd 后重开比分和手牌必须清干净。能做的加分交互里我首推快捷键H 要牌、S 停牌、R 重开。实现时在 MainWindow 里安装QShortcut每个快捷键直接调用GameModel的公共槽函数不经过按钮点击这也是对状态守卫的一次检验。其次是局数统计在 GameModel 里维护wins_、losses_、pushes_三个整数finishRound时更新界面用三个只读 QLabel 展示。注意别把统计逻辑堆在 TableWidget 里否则自绘代码会越来越难维护。最后是我自己的一个习惯每次改完代码先连续点十次“要牌”再快速点“停牌”然后立刻“重新开局”最后把窗口拖几个位置。这一套动作能在半分钟内暴露大多数状态机问题。这个课设的价值不在界面多炫而在于你能否把“事件驱动 状态变化”讲得让老师点头。希望这些经验和代码骨架能帮你少踩几个坑顺利把项目交付出去。本文还有配套的精品资源点击获取