ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Qt可视化21点游戏课设全复盘:从逻辑层到界面实现

2026/9/1 7:35:38 拓冰建站 浏览量
Qt可视化21点游戏课设全复盘:从逻辑层到界面实现 简介一份基于Qt框架开发的21点游戏课设完整项目面向需要入门Qt GUI编程或完成课程设计的初学者项目采用Qt 5.12实现包含完整的游戏流程玩家可通过按钮要牌、停牌、加倍与电脑庄家对战涉及牌堆洗牌、点数计算、爆牌判断与胜负判定等核心逻辑。资源包共86个文件压缩后约2.2MB其中58个PNG与3个JPG为牌面、背景等图形素材10个CPP与8个H为功能源码2个UI为界面设计文件另有QRC资源管理、PRO工程配置及ICO图标等结构清晰便于直接阅读和二次开发。该资源已有1469人浏览学习。通过这份源码读者可以学会Qt窗体布局、信号与槽事件处理、随机发牌算法以及自定义绘图等实用技能也能掌握如何用资源系统管理多张卡牌图片。适合作为Qt课程设计的参考项目也可在此基础上扩展多人模式或动画效果。 在动笔之前先交代一下背景这是一门课设的完整复盘题目就是“Qt可视化界面版21点游戏”。我当时选这个题主要看中了它逻辑链完整、展示效果好而且开发量在两周课设的时间线内完全可控。如果你也在准备类似的课设项目或者刚接触 Qt 想找一个能跑通全流程的练手案例这篇内容基本可以让你少走我当初趟过的那一堆弯路。1. 课题选型、技术栈与整体类设计1.1 为什么选21点而不是计算器或者贪吃蛇课设选题有个很现实的标准不能让老师觉得太简单也不能让自己做不完。计算器和贪吃蛇属于第一眼就被看穿工作量的选题图书管理系统又容易陷入“业务逻辑一堆界面全是表单”的无聊循环。21点这个游戏恰好卡在一个很舒服的位置——有明确的随机过程洗牌、发牌有完整的回合状态流转玩家要牌、停牌、庄家补牌、结算有不算复杂的胜负判定但又藏着一个“A 到底按 1 算还是按 11 算”的小算法点天然适合可视化界面牌面、点数、按钮状态都能直接映射到控件上。而且最重要的是21点的规则足够大众化答辩时老师不需要花时间理解你的业务背景可以直接把关注点放在你的代码设计和界面实现上。1.2 先拆分逻辑层和界面层别把代码全塞进按钮槽函数很多同学写 Qt 课设的习惯是先在 Designer 里拖控件然后往按钮的 clicked 信号里写游戏逻辑。写快了确实很爽但代码一多就乱成一团发牌逻辑、点数计算、胜负判断全部耦合在 MainWindow 里后面想加一个新功能改动会牵连一大片。我的做法是把游戏拆成两层逻辑层Card、Deck、Hand、GameController负责牌的表示、洗牌、手牌点数计算、庄家策略和胜负判定界面层MainWindow 只负责接收用户操作、调用逻辑层接口、刷新控件显示。这样拆的好处最直接的就是可以脱离界面单独测试游戏规则。比如在 main 函数里先写一段黑框代码模拟几万次胜负判定确认逻辑没问题再开始画界面。如果后面界面出了 bug你也可以很确定问题出在刷新或者信号连接上而不是规则计算错误。类图大致长这样GameController ├── Deck // 52张牌洗牌、发牌 ├── Hand // 一手牌维护手牌列表、计算总点数 ├── Card // 单张牌点数、花色、文本表示 ├── GameState // 状态机Idle / PlayerTurn / DealerTurn / Settled └── signals // 状态变化、发牌、结算等信号 MainWindow ├── UI控件 // 按钮、QLabel牌面、点数显示 ├── 槽函数 // 响应用户点击 └── 刷新函数 // 根据逻辑层状态重新绘制界面1.3 开发环境配置直接说结论Qt 版本我用的 5.15.2 LTS装的是 MinGW 64-bit 套件。如果你只是做课设没必要追求最新的 Qt 65.15 的资料多、踩坑答案多遇到问题一搜基本都有现成解决方案。编译器这一块建议选 MinGW不要选 MSVC。原因很简单MinGW 版本在部署 exe 时不需要额外的 VC 运行库对课设演示环境更友好。安装 Qt 时勾选 MinGW 64-bit 组件就够了不需要装 Charts、Data Visualization 这些额外模块后面加统计功能时再按需补。如果你问“能不能用 VS Code 写 Qt”能但我不建议在课设阶段折腾。VS Code 里配置 Qt 的 CMake、编译参数、调试器是一整套工程会消耗大量时间而且出错时排查链路更长。用 Qt Creator 配合自带的 .ui 设计器是最稳妥的路径。2. 规则建模和游戏状态机先写逻辑别急着碰界面2.1 规则到代码的映射表21点规则说起来简单但转成代码时要明确每一行的归属。我整理了一张映射表写代码时对照着来规则描述代码实现位置关键点52张牌四种花色13个点数Card Deck 构造函数用枚举表示花色和点数数字牌按面值计分J/Q/K按10计分Card::rankValue()边界情况A 特殊处理A 可计为1或11Hand::totalValue()先按11算超过21则减10玩家可以要牌或停牌GameController::playerHit() / playerStand()状态机限制操作条件庄家少于17点必须补牌GameController::dealerTurn()循环判断超过21点爆牌GameController每发一张牌后立即检查双方都不爆则比点数judge() 独立函数返回枚举结果2.2 A 的两种点数处理这是唯一有点算法含量的地方。A 可以算 1 也可以算 11处理逻辑不复杂先把手牌里所有 A 都当成 11 计算如果总分超过 21就把一个 A 改成按 1 算相当于总分减 10。有几个 A 就可以减几次直到不再爆牌为止。int Hand::totalValue() const { int total 0; int aces 0; for (const Card c : m_cards) { total c.rankValue(); if (c.rank() Card::Ace) { aces; } } while (total 21 aces 0) { total - 10; --aces; } return total; }这里有一个小细节rankValue()里把 A 先返回 11然后在 totalValue 里统一修正。如果你把 A 在 rankValue 里返回 1后面就很难再按 11 计算了除非你再遍历一次专门找 A。2.3 用状态机防止按钮乱点没有状态机的时候会出现一个经典 bug玩家在庄家回合还能点“要牌”按钮或者一局还没结束就能点“开始新一局”。解决办法不是在每个按钮槽函数里加一堆 if而是用一个清晰的状态枚举。enum class GameState { Idle, // 等待开局 PlayerTurn, // 玩家回合可要牌或停牌 DealerTurn, // 庄家自动补牌 Settled // 本局已结算 };状态转移规则就是Idle → PlayerTurn点击“开始新一局”发两张玩家牌、两张庄家牌PlayerTurn → PlayerTurn点击“要牌”且没有爆牌PlayerTurn → DealerTurn点击“停牌”或玩家爆牌DealerTurn → Settled庄家补牌结束进入胜负判定Settled → Idle点击“再来一局”。按钮的 enabled 属性完全由状态驱动。比如 Idle 状态下“要牌”和“停牌”一定是灰的DealerTurn 状态下三个按钮全部灰掉。这样你在槽函数里就不用再判断“这个操作当前是否合法”因为入口已经被状态机堵死了。2.4 Fisher-Yates 洗牌与随机数来源洗牌算法我在课设里特意写了 Fisher-Yates原因是可以讲清楚“为什么每张牌出现在任意位置的概率是相等的”。这属于答辩时的加分点。void Deck::shuffle() { std::random_device rd; std::mt19937 gen(rd()); for (int i m_cards.size() - 1; i 0; --i) { std::uniform_int_distributionint dist(0, i); std::swap(m_cards[i], m_cards[dist(gen)]); } }用std::random_devicestd::mt19937而不是qsrand或者srand核心原因是随机数质量更好。你不需要在界面层关心种子问题random_device会从系统熵源获取种子每次运行都能得到不同的序列。2.5 先用 qDebug 验证逻辑层界面还没搭好的时候我会在 main 函数里手动跑几局逻辑GameController game; game.startNewRound(); qDebug() 玩家点数: game.playerHand().totalValue(); while (game.state() GameState::PlayerTurn) { game.playerHit(); // 自动要牌到爆为止用于测试 } if (game.state() GameState::DealerTurn) { game.dealerTurn(); } qDebug() 结果: judgeResultText(game.result());这一步非常关键。如果逻辑层有 bug你会在这里就发现而不是等到界面全部做完后一边点按钮一边猜是界面问题还是逻辑问题。3. 用 Qt 做可视化牌桌布局、卡片绘制、信号槽与动画3.1 界面布局一个典型的上下三段结构我采用的是 QMainWindow 作为主窗口中央区域放一个 QVBoxLayout从上到下分成三个区域顶部状态栏区域显示当前局数、玩家胜/负/平统计以及庄家当前点数中间牌桌区域水平方向分两半左边是庄家手牌区右边是玩家手牌区各区域用 QHBoxLayout 动态添加牌面 QLabel底部操作区域三个 QPushButton分别是“要牌”“停牌”“开始新一局”。窗口尺寸我用的是 960x600牌桌区域用 QWidget 作为容器设置一个深绿色背景模拟真实赌桌的绒布质感。这个背景色只需要一行样式表ui-tableArea-setStyleSheet(background-color: #1e5631;);3.2 用 QLabel QSS 绘制卡牌不依赖图片资源课设作品最怕依赖外部图片资源。如果你加载扑克牌图片用相对路径演示时文件目录一动就全盘崩溃。我的方案是直接用 QLabel 配合样式表画卡牌完全不需要图片文件。QLabel* MainWindow::createCardLabel(const Card card, bool faceDown) { auto* label new QLabel(this); label-setFixedSize(90, 126); if (faceDown) { label-setText(?); label-setStyleSheet( QLabel { background: #2255aa; color: white; border: 2px solid #fff; border-radius: 8px; font-size: 32px; font-weight: bold; }); } else { QString display QString(%1\n%2) .arg(card.rankText()) .arg(card.suitText()); QString color (card.suit() Card::Hearts || card.suit() Card::Diamonds) ? #cc0000 : #111; label-setText(display); label-setStyleSheet(QString( QLabel { background: #fff; color: %1; border: 1px solid #aaa; border-radius: 8px; font-size: 22px; font-weight: bold; }).arg(color)); } label-setAlignment(Qt::AlignCenter); return label; }花色符号rankText()和suitText()我在 Card 类里已经封装好比如QString(♥)这种。Qt 的 QString 支持 Unicode 字符直接写中文和符号都没有问题。3.3 按钮与信号槽的连接方式推荐在构造函数里用connect显式连接而不是依赖 on_xxx_clicked 自动连接。显式连接更清晰而且不容易因为改了控件 objectName 导致槽函数失效。connect(ui-btnHit, QPushButton::clicked, this, MainWindow::onHitClicked); connect(ui-btnStand, QPushButton::clicked, this, MainWindow::onStandClicked); connect(ui-btnNewGame, QPushButton::clicked, this, MainWindow::onNewGameClicked);在onHitClicked里做的事情非常少void MainWindow::onHitClicked() { m_game.playerHit(); refreshCardArea(); if (m_game.state() GameState::Settled) { showResult(); } }refreshCardArea()会清空牌区 layout然后根据 GameController 里的手牌重新生成 QLabel。这里有个性能问题需要提一下每次都把所有牌清空重建在牌数不超过十几张的 21 点里完全不是问题不需要做增量更新。3.4 一张一张发牌QTimer 与 UI 假死的关键区别我在第一版里处理发牌动画时直接用了一个 while 循环加延时。结果运行后整个窗口卡死牌要等到循环结束才全部刷新出来。原因在于 Qt 的界面重绘是依赖事件循环的你在按钮的槽函数里阻塞住了事件循环重绘请求根本没有机会执行。正确的思路是把“发下一张牌”这个动作放进事件循环里用 QTimer 分段触发。比如开局时用 QTimer 每 400ms 发一张牌m_dealTimer new QTimer(this); m_dealTimer-setInterval(400); connect(m_dealTimer, QTimer::timeout, this, MainWindow::dealOneCardFromOpening); m_dealTimer-start();每次timeout触发时发一张牌并刷新界面发完四张后调用stop()并切换到玩家回合。这样界面在两张牌之间会有短暂的停顿看起来就像真实发牌过程。4. 课设过程中踩过的坑与调试记录这一节算是我整个开发过程里最有价值的部分。这些坑未必都会在教程里出现但几乎每个 Qt 新手都会在某一天撞上。4.1 QComboBox 重载信号导致编译失败一开始我想在设置界面里用一个下拉框让玩家选择“是否启用庄家提示”写连接时直接用了currentIndexChanged结果编译器报错 no matching function。查了文档才发现这个信号在 Qt 5.7 之后有两个重载版本一个传 int一个传 QString直接把函数指针写进去无法确定类型。解决办法是用qOverload指定重载版本connect(combo, qOverloadint(QComboBox::currentIndexChanged), this, MainWindow::onComboChanged);这个坑很小但几乎每个 Qt 新手都会遇到一次。4.2 卡牌叠放效果布局做到不了的事就别硬做我最初的牌区布局用 QHBoxLayout期望把牌一张张并排摆好。展示没问题但缺少牌桌的叠放感。为了像真实牌局那样牌与牌之间有重叠我试图给 layout 设置负的 spacing结果发现 QHBoxLayout 的 spacing 只能是非负整数。最后我放弃了布局方案改成手写坐标定位for (int i 0; i cards.size(); i) { QLabel* label createCardLabel(cards[i], faceDown); label-setParent(ui-playerCardArea); label-setGeometry(30 i * 55, 20, 90, 126); label-show(); }这样牌与牌之间就有 55 像素的偏移后面一张牌会压住前面一张的右边部分视觉上就很接近真牌桌的叠放效果。缺点是不方便用布局自动管理但在牌数很少的 21 点场景下完全够用。4.3 发牌按钮连点导致的重复发牌刚开始状态机不完善时连续点击“要牌”按钮会出现一次点下去发两张牌的情况。原因是按钮点击信号发出后槽函数先执行了玩家手牌添加和界面刷新但如果逻辑层没有校验当前状态下一次点击仍然能进入发牌分支。修复方法是把状态检查放在所有槽函数的最前面void MainWindow::onHitClicked() { if (m_game.state() ! GameState::PlayerTurn) return; // 以下才是真正要牌的逻辑 }这实际上不是界面问题而是业务逻辑层的状态约束没做好。如果你的状态机设计得像 2.3 节那样严格按钮的 enabled 属性会直接由状态驱动连点问题自然就不存在。4.4 中文乱码问题课设报告和代码里全是中文注释如果你在 Windows 下用 MSVC 编译源码文件保存为 GBK那 QString 里直接写中文字符串“要牌”“停牌”很可能显示为乱码。解决方案有两个一是把 Qt Creator 里的文件编码统一改成 UTF-8二是使用QStringLiteral包裹中文字符串。我在项目里统一用了 UTF-8 编码并在 .pro 文件里加了一行编译参数QMAKE_CXXFLAGS -utf-8这样无论是注释还是中文字符串都能在 MSVC 下正常编译。如果你用 MinGW 编译器这个问题的概率会低很多。4.5 发布 exe 时缺少运行库课设提交时经常要把 exe 拷到别的机器上演示。直接用 build 目录里的 exe 是跑不起来的它依赖 Qt 的动态链接库。Qt 提供了一个工具叫 windeployqt在命令行里执行windeployqt 21point.exe它会自动把需要的 Qt DLL 和插件目录复制到 exe 旁边。常见问题是忘掉 Qt platform 插件导致目标机器报错“could not find the qt platform plugin windows”。5. 从“能跑”到“能拿高分”的几个加分设计如果你的时间允许课设做完基础版之后这几个方向值得考虑。它们都不会大幅增加代码量但会显著提升项目完成度。5.1 战绩统计持久化每局结束后把结果写入本地 JSON 文件下次启动时读取并在顶部标签显示。Qt 处理 JSON 非常简单QFile file(records.json); if (file.open(QIODevice::ReadOnly)) { QJsonDocument doc QJsonDocument::fromJson(file.readAll()); QJsonObject obj doc.object(); int wins obj[wins].toInt(); // ... }这个功能能让老师看到你在数据持久化方面有一手而且写起来不超过二十行。5.2 用 QFileDialog 加载自定义背景图我在设置菜单里加了一个“选择牌桌背景”的功能用 QFileDialog 让用户选一张图片作为牌桌区域的背景。这正好用上了 Qt 的对话框能力也算是一个小而完整的功能点。QString path QFileDialog::getOpenFileName(this, 选择背景图, QString(), Images (*.png *.jpg *.bmp)); if (!path.isEmpty()) { QPixmap pix(path); ui-tableArea-setAutoFillBackground(true); QPalette pal; pal.setBrush(QPalette::Window, pix); ui-tableArea-setPalette(pal); }5.3 显示 A 的双重点数在界面里显示手牌总点数时如果手牌里有 A可以同时显示两种可能的点数比如“14 / 24”。这个小细节能证明你理解了软手牌的概念答辩时随口解释一下会是一个很好的加分点。5.4 答辩时可以主动讲清楚的三件事为什么拆分了逻辑层和界面层以及这样拆的好处Fisher-Yates 洗牌为什么是均匀随机状态机如何避免非法操作以及 UI 线程为什么不能用 sleep 做动画。这三件事如果能在答辩时连贯讲下来比任何花哨的功能都更能体现你的工程思维。最后再分享一点操作性建议我实际做完这个课设最大的收获不是 UI 设计也不是某个 Qt API 的记忆而是对“界面刷新依赖事件循环”这个机制有了真实体感。当你亲手把一个界面卡死的 bug 修到能流畅发牌时你对 Qt 的理解会从“能拖控件”跳到“能理解它在底层怎么工作”。项目里我把逻辑层和界面层的代码完全分开所以后面加音效、加分牌动画、扩展 AI 对手都不会伤筋动骨。如果你现在还在犹豫要不要用 21 点做 Qt 课设我的建议很直接选它但先把逻辑层用纯 C 写好确认规则完全正确再开始画界面。本文还有配套的精品资源点击获取