简介面向初学者的Qt可视化21点游戏课程设计完整工程包基于Qt 5.12开发适合巩固C与Qt GUI编程、事件处理、数据结构及游戏逻辑设计等知识点。资源包共86个文件压缩后仅2.2MB包含10个cpp源码、8个h头文件、2个ui界面文件、58个png牌面与背景素材以及qrc、pro、ico、py等工程与辅助文件整体结构清晰便于直接打开编译和学习。游戏实现了玩家与电脑的对战流程支持要牌、停牌等操作并处理发牌、计分、爆牌判断等核心规则代码分层明确包含begin、scen、model、player等多个模块适合课程设计或新手入门参考。已有1471人学习下载对希望通过实际项目掌握Qt界面开发与21点算法实现的读者来说是一份不错的实践资料。1. 这个课设为什么值得用 Qt 好好做一遍不少人的课程设计用的是控制台版 21 点黑底白字输入 1 要牌、2 停牌跑完交差。这种实现能过但分数通常很平庸因为课程设计考察的不是你会不会写 if/else而是你有没有把一个完整的小系统组织起来。Qt 可视化界面版 21 点游戏课设恰好覆盖了这些考察点对象建模、事件驱动、界面与逻辑分离、状态管理。它不是一个“算法题”而是一个小型桌面应用做完之后你能讲清楚每一块为什么这样设计这就比大部分只会贴代码的同学高出一个身位。这篇文章按我自己做同类项目的顺序来写先搭工程骨架再写游戏规则引擎然后接信号槽和界面布局最后专门列一批课设里高频翻车的细节。适合正在选课设方向、C 刚入门、或者已经写了控制台版想升级成可视化版的人。全程用 Qt Widgets C不涉及 QML因为课设答辩时 Widgets 的信号槽机制更容易讲透。2. 先把工程的骨架搭对Qt Widgets 项目的四个基础决策2.1 控制台版到界面版为什么选 Qt Widgets 而不是 QML做可视化 21 点常见路径有两条Qt Widgets 和 Qt Quick/QML。很多初学者看 QML 写界面像写网页觉得“现代”“好看”就选了 QML。但如果你这门课设是用 C 交的我建议优先 Widgets。原因很实在Widgets 的信号槽是 Qt 最核心的机制答辩时老师问“按钮点击之后程序怎么知道你点过了”你可以直接画一条信号槽链路而 QML 里大量交互逻辑由 QML 引擎替你处理了反而不好展开讲。还有一个细节QML 的动画和样式确实更灵活但课程设计评分里“界面美观”通常只占一小部分更多的是“功能完整性”和“代码结构”。Widgets 用 QSS 样式表也能做出不难看的界面——深绿色桌面、白色牌面、红色按钮足够了。对比下来我一般会这样建议维度Qt WidgetsQt Quick/QML信号槽讲解直观适合答辩需要额外讲 QML 与 C 的交互布局系统QLayout 体系与 C 强绑定类 CSS 布局偏前端思维动画能力弱需要 QPropertyAnimation 辅助强内置动画框架课设通过率稳容易在“为什么用 QML”上被追问入门门槛偏低需要同时学两套语言如果你之前的课设代码全是控制台程序Widgets 是过渡最平滑的你仍然在写 C 类、成员函数、构造函数只是多了一层界面类。2.2 手写 CMake 还是 qmake课设场景下的选择Qt 自带 qmake新建项目时默认生成 .pro 文件点几下就能跑。但如果你有闲工夫我建议手写 CMakeLists.txt。理由不是为了炫技而是 CMake 在实习和实际项目里是主流早接触不吃亏。而且 CMake 对多文件工程的管理比 qmake 清晰头文件、源文件、资源文件都在一个文件里列出来答辩时能直接指着说“工程结构在这里定义”。一个最小的 Qt Widgets 工程的 CMakeLists.txt 长这样cmake_minimum_required(VERSION 3.16) project(QtBlackjack VERSION 1.0 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) # Qt 的自动 moc 处理 set(CMAKE_AUTOMOC ON) set(CMAKE_AUTORCC ON) set(CMAKE_AUTOUIC ON) find_package(Qt6 REQUIRED COMPONENTS Widgets) set(SOURCES main.cpp MainWindow.cpp Card.cpp Hand.cpp Deck.cpp Game.cpp ) set(HEADERS MainWindow.h Card.h Hand.h Deck.h Game.h ) add_executable(QtBlackjack ${SOURCES} ${HEADERS}) target_link_libraries(QtBlackjack PRIVATE Qt6::Widgets)这里有几个参数要注意CMAKE_AUTOMOC 必须打开因为 Qt 的信号槽需要元对象编译器预处理如果你没开这个开关编译时会报一堆“未定义 vtable”的错误。set(CMAKE_CXX_STANDARD 17) 不是必须的Qt6 本身要求 C17Qt5 用 C11 也能编过但新工程直接上 17 省心。另一种做法是用 Qt Creator 的图形化新建向导它会自动生成 CMakeLists.txt。我手动写是因为课设里我习惯把文件列表控制得干干净净每个类一个 .h 一个 .cpp向导生成的模板总带一堆用不到的注释看着就烦。2.3 最小可运行工程主窗口、按钮和首次编译骨架搭好后的第一个目标是弹出一个窗口上面有一个“开始游戏”按钮点了没有任何反应但也不崩溃。这个目标看起来很幼稚但它帮你验证了 Qt 环境、编译链路和窗口系统三者都正常。这一步跑不通后面写再多代码都是白搭。main.cpp 长这样#include QApplication #include MainWindow.h int main(int argc, char *argv[]) { QApplication app(argc, argv); MainWindow window; window.show(); return app.exec(); }MainWindow.h 里只声明一个带 Q_OBJECT 的类和一个按钮。Q_OBJECT 是 Qt 元对象系统的入口没有它信号槽、tr() 这些全不能正常工作#pragma once #include QMainWindow class QPushButton; class MainWindow : public QMainWindow { Q_OBJECT public: explicit MainWindow(QWidget *parent nullptr); private slots: void onStartClicked(); private: QPushButton *m_startButton; };MainWindow.cpp 里把按钮放到中央#include MainWindow.h #include QPushButton #include QVBoxLayout MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) { setWindowTitle(QStringLiteral(21点)); resize(800, 600); m_startButton new QPushButton(QStringLiteral(开始游戏), this); auto *central new QWidget(this); auto *layout new QVBoxLayout(central); layout-addWidget(m_startButton); setCentralWidget(central); connect(m_startButton, QPushButton::clicked, this, MainWindow::onStartClicked); } void MainWindow::onStartClicked() { // 先不实现验证信号槽链路即可 }编译方式有两种在 Qt Creator 里直接按运行按钮或者在终端里 mkdir build cd build cmake .. make。如果编译报“找不到 Qt6::Widgets”先查 find_package 的路径大概率是 CMAKE_PREFIX_PATH 没指到 Qt 安装目录。我一般会刻意让第一个按钮的信号槽以最简形式跑通再往里面填东西。很多同学喜欢一口气写完再编译结果报错几十条分不清是环境问题还是代码问题心态直接崩掉。先让骨架跑起来后面每加一个类编译一次出错了范围很小这是做课设最省时间的习惯。3. 21 点的核心不是界面而是规则引擎牌堆、手牌与庄家 AI3.1 用结构体还是类来建模牌与手牌很多人写 21 点把牌直接存成 int1 到 13花色不要了。这样写起来确实快但界面版要用“红桃 A”这种字符串去显示你还得在显示前加一层转换。更顺的做法是定义一个 Card 结构体花色和点数分开存。我用的 Card 定义如下enum class Suit { Hearts, Diamonds, Clubs, Spades }; enum class Rank { Ace 1, Two, Three, Four, Five, Six, Seven, Eight, Nine, Ten, Jack, Queen, King }; struct Card { Suit suit; Rank rank; };为什么用 enum class 而不用 const int因为 enum class 有类型检查你传一个 Rank 给需要 Suit 的地方编译器会报错const int 不会。这种类型安全在课设里能帮你提前挡掉一批低级 bug。牌面字符串的转换函数一般放在 Card.cpp 里QString cardToString(const Card card) { QString rankStr; switch (card.rank) { case Rank::Ace: rankStr QStringLiteral(A); break; case Rank::Jack: rankStr QStringLiteral(J); break; case Rank::Queen: rankStr QStringLiteral(Q); break; case Rank::King: rankStr QStringLiteral(K); break; default: rankStr QString::number(static_castint(card.rank)); break; } QString suitStr; switch (card.suit) { case Suit::Hearts: suitStr QStringLiteral(红桃); break; case Suit::Diamonds: suitStr QStringLiteral(方块); break; case Suit::Clubs: suitStr QStringLiteral(梅花); break; case Suit::Spades: suitStr QStringLiteral(黑桃); break; } return suitStr rankStr; }这里不建议在 CardToString 里用 Unicode 花色符号♠♥♦♣跨平台字体兼容性很玄学Windows 上好好的Linux 上一堆豆腐块。用中文花色的字符不会出错丑一点但稳定。如果你的目标是“好看”可以后续用贴图替代。3.2 发牌、记牌与 A 牌的双值计算这一节是整个 21 点规则的命门也是最容易写错的点。牌堆用 QVector 存 52 张牌洗牌用 QRandomGenerator 而不是 qrand分牌时从尾部取牌这样避免对中间元素做删除导致 QVector 元素搬移。class Deck { public: Deck() { reset(); } void reset() { m_cards.clear(); for (int s 0; s 4; s) { for (int r 1; r 13; r) { m_cards.append(Card{ static_castSuit(s), static_castRank(r) }); } } shuffle(); } Card dealCard() { Q_ASSERT(!m_cards.isEmpty()); return m_cards.takeLast(); } private: void shuffle() { // QRandomGenerator::global() 是线程安全的每次生成不同序列 std::shuffle(m_cards.begin(), m_cards.end(), QRandomGenerator::global()); } QVectorCard m_cards; };重点说 A 牌的双值。一手牌的点数计算逻辑是先把所有牌按 A11 加总如果总点数超过 21 且手牌里有 A就把 A 从 11 降成 1每次降一点。这个策略能保证点数最大化但不爆牌。int handValue(const QVectorCard hand) { int total 0; int aceCount 0; for (const Card card : hand) { if (card.rank Rank::Ace) { total 11; aceCount; } else if (card.rank Rank::Ten) { total 10; } else { total static_castint(card.rank); } } while (total 21 aceCount 0) { total - 10; --aceCount; } return total; }这段代码的 key point 在 while 循环A 的数量可能不止一张所以不能写成单次 if。举例一手牌 A A 9先算 11 11 9 31第一次减 10 得 21循环结束返回 21。如果只减一次而不写循环A A A 8 41减一次后 31 仍然爆牌结果就错了。dealing 的顺序上标准 21 点给两轮第一轮给玩家和庄家各发两张第二轮看是否需要补牌。这里要注意 UI 上显示庄家的第一张牌是暗牌规则引擎里庄家的手牌是完整的两张只是显示层把第一张盖住。3.3 庄家 AI 的三个状态与延时出牌庄家 AI 是 21 点里最容易被学生忽略但老师一定会问的部分庄家在什么时候要牌、什么时候停牌标准规则是“小于 17 必须要大于等于 17 停”有一种叫“软 17”的变体要求庄家在 A 算 11 时点数恰好为 17 也要继续要。我建议课设里做一个可配置的开关答辩时能讲出区别。伪代码级别的实现enum class Action { Hit, Stand }; Action dealerAction(const QVectorCard dealerHand) { int value handValue(dealerHand); if (value 17) { return Action::Hit; } // 常见规则硬 17 停软 17 看配置 if (value 17 isSoft17(dealerHand) m_hitSoft17) { return Action::Hit; } return Action::Stand; }isSoft17 的实现是手牌里有 A、且 A 不降值的情况下点数等于 17。其实 handValue 返回 17 时还要再额外判断是否手牌里含 A因为硬 17 和软 17 的点数都是 17。bool isSoft17(const QVectorCard hand) { int value handValue(hand); if (value ! 17) { return false; } for (const Card card : hand) { if (card.rank Rank::Ace) { return true; } } return false; }这里藏着一个很多人会踩的坑handValue 里已经把 A 降为 1 了所以如果手牌是 A 6handValue 返回 17isSoft17 返回 true。如果手牌是 10 7也是 17但 isSoft17 返回 false。逻辑上没问题但关键是调用顺序——先取 handValue 再判断软硬不能反过来。庄家出牌的另一大坑是延时。很多课设里庄家“等一秒再要牌”是用 sleep 实现的在槽函数里 QThread::sleep(1) 或者用 for 循环空转。这在原理上没错但效果是灾难界面整个冻结窗口拖动不了按钮点了没反应看起来就像程序死了。正确做法是用 QTimer 单发延时事件循环还继续转。void Game::dealerTurn() { if (dealerAction(m_dealerHand) Action::Hit) { m_dealerHand.append(m_deck.dealCard()); QTimer::singleShot(800, this, [this]() { emit dealerHandChanged(m_dealerHand); dealerTurn(); // 递归才可重入窗口不会卡死 }); } else { emit roundFinished(); } }QTimer::singleShot 的第一个参数是毫秒800 表示 0.8 秒后再执行 lambda。关键点是每次要牌都通过延时再次调用 dealerTurn而不是在 while 循环里连续调用——如果写成 while界面重绘的机会都没有玩家会看到庄家瞬间把所有牌出完。3.4 游戏状态机一局牌的生命周期界面版的 21 点不像控制台版那样一问一答玩家可以随时点按钮。所以你要用状态来约束“现在能做什么”。最简单够用的状态机是五态准备中、游戏中、玩家停牌、庄家行动、结算完成。状态可执行操作界面表现Ready下注、开始按钮区可点Playing要牌、停牌玩家手牌区更新PlayerStood无操作等待庄家显示等待动画或日志DealerTurn无操作庄家牌逐张翻出Finished开始新一局显示胜负和分数这个表直接对应界面按钮的 enable/disable 集合。比如在 Ready 状态要牌和停牌按钮都是禁用的在 Playing 状态开始按钮禁用要牌和停牌启用。写一个 updateUiByState() 集中处理这些开关比在每一处代码里手动 setEnabled 好维护得多。游戏逻辑放一个独立的 Game 类里不放在 MainWindow 里。这样好处是将来你想加一个命令行测试版本可以直接复用 Game 类不碰界面代码。逻辑与界面分离是课程设计里最值得拿上台面的设计点。4. 让界面真正“可视化”信号槽驱动的一局完整流程4.1 从按钮到发牌一条完整的信号槽链路设计可视化版的核心就是信号槽。不要一个槽里干所有事而是把流程拆成“按钮 → 业务 → 界面刷新”三段。以“开始按钮”为例完整链路是用户点击“开始游戏”Qt 发出 QPushButton::clicked 信号MainWindow 的 onStartClicked 槽被调用onStartClicked 调用 Game::startNewRound()Game 发牌、计算初始状态发出 roundStarted / handChanged 信号MainWindow 里的槽函数接收这些信号更新牌面显示和按钮状态代码骨架// MainWindow.cpp 构造函数 connect(m_startButton, QPushButton::clicked, this, MainWindow::onStartClicked); connect(m_game, Game::playerHandChanged, this, MainWindow::updatePlayerHandLabel); connect(m_game, Game::dealerHandChanged, this, MainWindow::updateDealerHandLabel); connect(m_game, Game::stateChanged, this, MainWindow::updateUiByState);Game 类里发信号class Game : public QObject { Q_OBJECT signals: void playerHandChanged(const QVectorCard playerHand); void dealerHandChanged(const QVectorCard dealerHand); void stateChanged(GameState state); };这里有个经验信号参数尽量用 QVector 这种自包含类型不要用 QString 拼好的手牌字符串。因为信号接收方可能要同时做两件事——显示和计算。如果你只发字符串过去接收方想用点数做判断还得再解析字符串非常难受。4.2 牌桌布局用 QGridLayout 和 QSplitter 搭出三个区域牌桌界面最少要有三块庄家区顶部、玩家区中间、控制区底部。控制区又包含下注输入框、要牌按钮、停牌按钮、开始按钮。布局我推荐的做法是主窗口根布局用 QVBoxLayout庄家区和玩家区各套一个 QHBoxLayout用来横向摆放牌面 QLabel控制区用 QGridLayout按钮按行排列手写布局的代码如下auto *central new QWidget(this); auto *rootLayout new QVBoxLayout(central); auto *dealerArea new QWidget(central); auto *dealerLayout new QHBoxLayout(dealerArea); m_dealerHandLabel new QLabel(QStringLiteral(庄家), dealerArea); m_dealerHandLabel-setMinimumHeight(120); dealerLayout-addWidget(m_dealerHandLabel); rootLayout-addWidget(dealerArea); auto *playerArea new QWidget(central); auto *playerLayout new QHBoxLayout(playerArea); m_playerHandLabel new QLabel(QStringLiteral(玩家), playerArea); m_playerHandLabel-setMinimumHeight(120); playerLayout-addWidget(m_playerHandLabel); rootLayout-addWidget(playerArea); auto *controlArea new QWidget(central); auto *controlLayout new QGridLayout(controlArea); m_hitButton new QPushButton(QStringLiteral(要牌), controlArea); m_standButton new QPushButton(QStringLiteral(停牌), controlArea); m_startButton new QPushButton(QStringLiteral(开始新一局), controlArea); controlLayout-addWidget(m_hitButton, 0, 0); controlLayout-addWidget(m_standButton, 0, 1); controlLayout-addWidget(m_startButton, 1, 0, 1, 2); rootLayout-addWidget(controlArea); setCentralWidget(central);布局的关键是不要用固定坐标去摆放 QLabel。你永远不知道用户窗口会被拉到多大固定坐标在 Windows 下高分屏缩放下更是灾难。QLayout 的伸缩规则是靠 stretch 系数控制的QHBoxLayout 里多个牌面标签之间如果想留出间距可以在 addWidget 时传一个 stretch 参数。牌面的更新函数可以设计成“清空重填”而不是“增量添加”。清空重填简单直接代码量少21 点一局最多 10 张牌左右性能完全不是问题void MainWindow::updatePlayerHandLabel(const QVectorCard hand) { QStringList parts; parts QStringLiteral(玩家); for (const Card card : hand) { parts cardToString(card); } parts QStringLiteral(点数%1).arg(handValue(hand)); m_playerHandLabel-setText(parts.join(QStringLiteral( ))); }4.3 牌面显示的三套方案文字、Unicode 花色、贴图显示牌面有三种做法放在一起对比一下方案实现成本视觉效果风险纯文字“红桃 A”最低一般无Unicode 花色 文字低较好字体不支持时显示方块贴图QPixmap中最好需要自己准备 52 张图或代码绘制我建议课设从纯文字起步跑通后再换贴图。有个折中做法是绘制牌面的函数里用 QPainter 画白色圆角和黑色点数不需要准备图片资源视觉效果也说得过去。但绘制代码量不小如果你时间紧张先别碰。4.4 按钮状态机五态与控件启停的映射用一张表把状态和按钮开关的关系明确写出来避免代码里到处散落 if/else游戏状态开始按钮要牌按钮停牌按钮Ready启用禁用禁用Playing禁用启用启用PlayerStood禁用禁用禁用DealerTurn禁用禁用禁用Finished启用禁用禁用对应代码void MainWindow::updateUiByState(GameState state) { switch (state) { case GameState::Ready: m_startButton-setEnabled(true); m_hitButton-setEnabled(false); m_standButton-setEnabled(false); break; case GameState::Playing: m_startButton-setEnabled(false); m_hitButton-setEnabled(true); m_standButton-setEnabled(true); break; case GameState::Finished: m_startButton-setEnabled(true); m_hitButton-setEnabled(false); m_standButton-setEnabled(false); break; default: // PlayerStood / DealerTurn 状态全部禁用 m_startButton-setEnabled(false); m_hitButton-setEnabled(false); m_standButton-setEnabled(false); break; } }这里要强调的是后三个状态PlayerStood / DealerTurn / Finished中Finished 才需要重新启用开始按钮。有些课设代码在庄家行动时忘了禁用按钮玩家疯狂点“开始”导致一局还没结束又开了新局状态全乱掉。所以状态机和 UI 更新的绑定一定要做全而不是只在个别状态里处理。5. 课设里频繁翻车的七个细节从信号连接到界面冻结5.1 点击“要牌”后整个窗口卡死现象按钮点下去之后窗口拖不动转圈过两三秒才恢复期间庄家牌一次性全部翻出。原因在槽函数里用了 QThread::sleep() 或 while 空转来做庄家延时主线程的事件循环被阻塞界面来不及重绘。解决延时逻辑全部改用 QTimer::singleShot把“等一下再行动”变成事件循环里的一次定时任务。sleep 是同步阻塞singleShot 是异步返回这是两者最本质的区别。同理任何耗时操作比如以后加动画都不建议放在槽函数里同步执行。5.2 A 牌点数计算在“双 A”时爆牌现象玩家拿到 A A 9点数算成 41直接判爆。原因常见的错误写法是先给 A 按 11 加然后只在手牌只有一张 A 时做一次减 10 的判断。双 A 或三 A 时减一次不够。解决用 3.2 节里的 while 循环减法每减一次代表一张 A 从 11 降到 1。这个 bug 很隐蔽单测时不容易暴露因为你一般只测 A9 这种一张 A 的案例。建议手工补测几组A A 9 应该是 21A A A 8 应该是 21A A 5 5 应该是 22单张 K A 是 21。5.3 每次重新洗牌后发牌的序列都一模一样现象Debug 模式下每局第一张牌永远是黑桃 A或某个固定牌。原因用了老式 qsrand(time(0))或者干脆没设置随机种子。另外 Qt 5.10 之前如果全局只设一次种子std::shuffle 也可能拿到同一个序列。解决用 QRandomGenerator::global()它内部维护线程安全的全局种子不需要手动 srand。如果还想让结果听起来可复现可以在 Deck 里加一个带种子参数的构造方便答辩时演示“同一副牌打两遍”。5.4 窗口拉大后牌面文字挤成一团现象窗口变宽牌面标签还是等宽排列字间距大得离谱拉高后牌面文字上下顶到边界。原因手工用 setGeometry 固定了标签位置和大小布局管理器没有生效或者标签的最小尺寸没有设置。解决统一走 QLayout 体系不要用 setGeometry。给手牌文字的 QLabel 设一个合理的 minimumHeight让布局管理器在 stretch 分配时有一个下限作为参考。还可以加一句 setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding)告诉布局组件可以伸缩。5.5 同一个槽函数被执行多次一局会发四张牌现象点一次“要牌”玩家手牌一次增加了两张甚至四张。原因常见的坑是在循环里 connect 了同一个信号或者在某个初始化函数里被重复调用两次 connect。信号槽默认是允许多次连接的你 connect 两次按钮点一次就会触发两次槽。解决在构造函数里集中初始化所有 connect不要在状态切换的代码里做连接。排查方法是在槽函数第一行加 qDebug() “hit called”如果点击一次却打印两次检查是不是在 startNewRound 里又 connect 了一遍。也可以用 Qt::UniqueConnection 作为第五个参数重复连接会跳过connect(m_hitButton, QPushButton::clicked, this, MainWindow::onHitClicked, Qt::UniqueConnection);5.6 用 Unicode 花色字符在部分系统上显示成方块现象黑桃、红桃、方块、梅花四个符号在 Windows 上正常到 Linux 或低配 Win 上变成“□”。原因DejaVu、宋体等字库里有这些符号但某些精简字体没有相应字形。这是字体覆盖率问题不是 Qt 的问题。解决课设最稳妥的方案是完全不用花色符号改用“红桃 A”这样的文本。如果用贴图方案就用 QPainter 画图形或准备四张小图不依赖字体。贴图方案里要用 QSvgRenderer 的话还得额外链接 svg 模块复杂度不值当。5.7 关闭窗口后进程还在后台跑现象点了 X 关闭窗口主窗口消失但任务管理器里进程还在CPU 占用归零。原因常见写法是在构造函数里 connect 了某种信号或者你隐藏了窗口但没有真正 close。另一个常见场景是你重写了 closeEvent 但忘了调用 event-accept()窗口进去了一种“不关闭也不显示”的状态。解决把关闭窗口作为最直接的退出路径确认槽里调用 QApplication::quit()并确保 closeEvent 里没有拦截。如果你在关闭窗口时想做资源清理重写 closeEvent 后在末尾调用 QMainWindow::closeEvent(event)。如果进程仍不退出检查是不是有非 daemon 的 QThread 还在运行明确 join 或 terminate但课设逻辑里一般不会有这种场景。6. 三个不超纲的进阶点让课设从“能用”变成“能讲”第一个推荐加的是发牌动画。不需要复杂的花式翻牌做一个 300ms 的牌面“滑入”即可用 QPropertyAnimation 操作 QLabel 的 pos 属性从屏幕外滑到目标格。注意动画期间要禁止按钮点击否则玩家在动画进行中狂点槽函数反复触发手牌序列乱掉。第二个推荐加的是牌局回放日志。在 Game 类的每个动作节点往 QFile 里追加一行 JSON 或纯文本记录包含第几局、玩家动作、得牌、当前点数、庄家动作、结算结果。这个功能写起来只有十几行但答辩时可以直接打开日志文件展示整场对局的流转过程比口述更有说服力。文件操作注意用追加模式打开QIODevice::Append别每次覆盖。第三个推荐加的是下注与计分。很多人做的 21 点没有筹码概念赢了输了只看一行文字。加一个整数 QSpinBox 作为下注输入赢牌加注、输牌扣分、Blackjack首两张牌就是 21 点按 1.5 倍赔。代码量不大但把游戏从“判断胜负”升级成了“牌局系统”这是答辩时能聊两三分钟的设计点。最后说个真实教训。我最早做这个课设时把所有逻辑都写在 MainWindow 的成员函数里发牌、要牌、结算全部在按钮槽里现写。做到庄家延时那一块时因为界面卡死调了两天才发现是 sleep 的问题而由于逻辑和界面缠在一起想在命令行下复现还复现不了。后来老老实实拆出 Game 类所有和界面无关的规则测试都在纯 C 环境里跑十分钟能验证完的改动再也不用靠肉眼点按钮去试了。如果你做的时候也遇到类似的别扭多半是代码边界切错了。这个方向值得你花一个周末做出骨架再花一个晚上做细节希望帮到你。本文还有配套的精品资源点击获取