用Qt打造21点课设:从信号槽到状态机的完整实践

📅 发布时间:2026/10/13 11:14:45
用Qt打造21点课设:从信号槽到状态机的完整实践
简介这是一份基于Qt 5.12开发的21点游戏课程设计资源采用C与Qt Widgets构建可视化桌面界面适合初学Qt或需要完成GUI课设的读者参考。资源共86个文件、2.2MB包含10个cpp源码、8个头文件、2个ui界面设计文件以及58个png牌面图片和qrc资源文件源码与图片素材齐全覆盖了游戏主逻辑、界面布局、事件响应等核心模块。已有1471人学习下载。压缩包内除完整工程文件.pro/.user外还附带了begin.py等辅助脚本便于直接编译运行或对比学习。通过阅读源码可掌握信号槽机制、布局管理、随机发牌与爆牌判断等关键知识点是一份结构清晰、可直接用于课设演示或二次扩展的实践案例。1. 用Qt做21点课设不只是交作业更是理解事件驱动很多人选21点做课程设计是因为规则简单、逻辑清晰但交一个控制台版本上去老师一句“界面呢”就能让整个学期的心血打回原形。用Qt做可视化界面版21点核心难点不在于你知道21点怎么玩而在于把“发牌、要牌、停牌”这些动作变成可点击的按钮事件让程序从顺序执行变成“用户点什么就响应什么”。这份资源从零搭了一个可运行的Qt牌桌覆盖布局、信号槽、状态机、计分与庄家AI代码结构可以直接复制到课设报告里也适合想通过一个小游戏快速理解Qt事件驱动的人。2. 从控制台到界面牌桌布局、数据模型与洗牌逻辑把控制台版改成界面版最容易犯的错是一上来就画控件画完了发现数据不知道往哪里塞。正确的顺序是先想清楚牌桌上有哪些区域每个区域放什么类型的数据最后才动手写窗口。界面只是数据的投影数据模型稳定了换皮肤都容易。2.1 界面布局三块区域一套盒子布局搞掂21点牌桌至少需要三个区玩家手牌区、庄家手牌区、操作按钮区。Qt里最省心的就是盒子布局QHBoxLayout / QVBoxLayout不需要绝对坐标窗口拉伸时控件自己找位置。from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel class Table(QWidget): def __init__(self): super().__init__() self.setWindowTitle(21点) self.resize(600, 400) root QVBoxLayout(self) # 庄家区一行放牌 self.dealer_box QHBoxLayout() root.addLayout(self.dealer_box) # 玩家区一行放牌 self.player_box QHBoxLayout() root.addLayout(self.player_box) # 信息条显示点数与结果 self.info QLabel(点击 开始 发牌) self.info.setMinimumHeight(30) root.addWidget(self.info) # 按钮区三个按钮横排 buttons QHBoxLayout() self.btn_start QPushButton(开始) self.btn_hit QPushButton(要牌) self.btn_stop QPushButton(停牌) self.btn_hit.setEnabled(False) self.btn_stop.setEnabled(False) buttons.addWidget(self.btn_start) buttons.addWidget(self.btn_hit) buttons.addWidget(self.btn_stop) root.addLayout(buttons)这里有一个细节玩家区和庄家区都是垂直方向上的独立一行用QHBoxLayout是让牌从左到右排列按钮区放在最下面符合操作习惯。btn_hit和btn_stop初始禁用防止游戏还没开始玩家就乱点。info标签负责显示“你的点数”“爆牌”这类动态信息比弹窗更自然。2.2 数据模型牌堆、手牌与游戏状态枚举界面上看到的是QWidget背后驱动它们的是数据。我用一个Game类来装所有游戏数据界面只负责把Game里的数据“画”出来。这样将来想加筹码、加多副牌都不用动界面代码。from enum import Enum import random class State(Enum): INIT 0 # 初始状态游戏未开始 PLAYER_TURN 1 # 玩家回合 DEALER_TURN 2 # 庄家回合 GAME_OVER 3 # 游戏结束 class Game: def __init__(self): self.deck [] # 牌堆字符串列表 self.player_cards [] # 玩家手牌 self.dealer_cards [] # 庄家手牌 self.state State.INIT def build_deck(self): suits [♠, ♥, ♦, ♣] ranks [A, 2, 3, 4, 5, 6, 7, 8, 9, 10, J, Q, K] self.deck [f{s}{r} for s in suits for r in ranks]用字符串表示牌的优点是显示方便比如♠A直接丢给QLabel就能画出来。缺点是解析点数时要多做一步后面calc_score里会处理。牌堆用列表的pop()模拟发牌发牌就是取走列表最后一个元素这也是真实赌场从牌堆顶部拿牌的习惯。2.3 洗牌手写Fisher-Yates而不是直接调shuffle答辩时老师最喜欢问“你怎么洗牌的”。如果你直接回答random.shuffle基本就没了下文。手写经典洗牌算法既能展示基本功又能控制随机种子方便测试。def shuffle(self): # Fisher-Yates / Knuth洗牌O(n)复杂度 for i in range(len(self.deck) - 1, 0, -1): j random.randint(0, i) self.deck[i], self.deck[j] self.deck[j], self.deck[i]参数说明循环从最后一个元素开始每次随机选一个索引j范围从0到当前索引i然后交换。这样每个排列出现的概率是等可能的。random.randint是闭区间所以用j random.randint(0, i)。如果要用固定种子复现测试就在调用shuffle前执行random.seed(42)注意这个种子只能在测试时用正式游戏不要固定。3. 把发牌、要牌、停牌变成按钮事件信号槽与状态流转控制台程序是“程序说了算”界面程序是“用户说了算”。用户可能在不该点击的时候猛点按钮程序必须学会拒绝。这就要靠状态机和信号槽配合。3.1 信号槽连接点击按钮如何驱动游戏Qt最核心的机制是信号槽。按钮被点击时发出clicked信号Qt事件循环把信号交给连接好的槽函数。槽函数就是普通函数但通过connect和按钮绑定后它就变成了“事件处理器”。self.btn_start.clicked.connect(self.start_game) self.btn_hit.clicked.connect(self.hit) self.btn_stop.clicked.connect(self.stop)clicked信号不带参数所以槽函数签名必须是无参的。如果想给槽函数传参数常见做法是lambda: self.hit(some_value)但不建议在这里用因为状态检查放在槽函数内部更安全。3.2 状态机防止玩家在错误时机点按钮我见过很多课设代码按钮点击后直接执行逻辑根本不问“现在能不能点”。结果玩家在开局前点“要牌”游戏直接崩。解决办法是定义State枚举每个槽函数第一行就查状态非法状态下直接return。def start_game(self): # 只能在初始或结束状态重新开始 if self.state ! State.INIT and self.state ! State.GAME_OVER: return self.build_deck() self.shuffle() # 初始化各发两张牌庄家第二张先隐藏简单实现不隐藏 self.player_cards [self.deck.pop(), self.deck.pop()] self.dealer_cards [self.deck.pop(), self.deck.pop()] self.state State.PLAYER_TURN self.btn_start.setEnabled(False) self.btn_hit.setEnabled(True) self.btn_stop.setEnabled(True) self.info.setText(f你的点数: {self.calc_score(self.player_cards)}) self.update_display() def hit(self): if self.state ! State.PLAYER_TURN: return self.player_cards.append(self.deck.pop()) if self.calc_score(self.player_cards) 21: self.state State.GAME_OVER self.info.setText(爆牌你输了) self.btn_hit.setEnabled(False) self.btn_stop.setEnabled(False) self.btn_start.setEnabled(True) else: self.info.setText(f你的点数: {self.calc_score(self.player_cards)}) self.update_display()这里的状态检查并不是“防御性编程”那么玄学而是界面程序的基本素养。按钮的启用状态其实也是一种状态玩家回合时hit和stop可用但为了做到“即使你通过快捷键或连点绕过禁用”槽函数里必须再检查一遍。两层防护出问题概率大大降低。3.3 更新界面动态添加QLabel与清空旧牌手牌数量是不固定的所以不能用写死的标签。每次刷新时先把布局里的旧标签全部清掉再根据当前手牌重新生成标签。这里有两个坑一是清空布局要用deleteLater否则控件内存泄漏二是布局的addWidget是往尾部加所以必须先清空再添加。def clear_layout(self, layout): while layout.count(): item layout.takeAt(0) widget item.widget() if widget is not None: widget.deleteLater() def update_display(self): self.clear_layout(self.player_box) self.clear_layout(self.dealer_box) for card in self.player_cards: lbl QLabel(card) lbl.setMinimumWidth(40) lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) self.player_box.addWidget(lbl) for card in self.dealer_cards: lbl QLabel(card) lbl.setMinimumWidth(40) lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) self.dealer_box.addWidget(lbl)参数说明setMinimumWidth(40)保证每张牌有固定宽度避免牌少时挤成一团。setAlignment让牌面上的花色和点数居中。清空布局时takeAt(0)会返回布局项item.widget()拿到控件指针deleteLater把它排进事件循环删除队列安全释放内存。4. 庄家自动要牌与输赢判定边界参数与规则实现21点的难点不在发牌而在“A算1还是11”和“庄家到底几点停”。这两个细节没做对整局游戏都是错的。我把计分和判定拆成独立函数这样每个逻辑都能单独测试。4.1 计分函数A的分值动态调整每张牌的点数不同A既可以算1也可以算11。简单实现是先把所有A当11如果总点数爆了再把A逐张变成1但要注意A的数量可能不止一张。def calc_score(self, cards): score 0 aces 0 for card in cards: # card示例: ♠A ♥10 ♦K # 花色占一个字符点数从第2个字符开始 rank card[1:] if rank A: aces 1 score 11 elif rank in (K, Q, J): score 10 else: score int(rank) # 只要总点数超过21且有A就把一个A从11降为1 while aces 0 and score 21: score - 10 aces - 1 return score这个实现的巧妙之处在于while循环如果手牌是A A 9初始分数是1111931第一次降A变成21循环条件score 21不满足退出。如果手牌是A A 9 3初始31第一次变21这时A还剩一个但score 21为假所以不会继续降。逻辑正好符合规则只有当A变成1能让你不爆牌时才变。4.2 庄家AI自动要牌直到17点庄家没有选择权规则是“不到17点就必须继续要牌”。这个功能用循环实现很简单但有个界面刷新问题如果在循环里疯狂append而不刷新玩家会看到庄家手牌瞬间变多体验很差。def dealer_turn(self): self.state State.DEALER_TURN self.btn_hit.setEnabled(False) self.btn_stop.setEnabled(False) while self.calc_score(self.dealer_cards) 17: self.dealer_cards.append(self.deck.pop()) self.update_display() QApplication.processEvents() # 强制刷新界面QApplication.processEvents()会把事件队列里的绘制事件处理掉。如果没有这行update_display()只是标记控件需要重绘真正绘制要等事件循环空闲结果就是所有牌一次性全部出现。参数17是停牌临界点如果要实现“软17”规则庄家有A但算11达到17时继续要牌需要额外判断手牌里是否包含可降值的A。4.3 输赢判定黑杰克、爆牌与平局游戏结束时比较双方点数但要注意几个边界条件爆牌直接输庄家爆牌玩家赢点数相同的平局。我习惯写一个独立函数返回字符串结果而不是在stop里堆逻辑。def stop(self): if self.state ! State.PLAYER_TURN: return self.dealer_turn() ps self.calc_score(self.player_cards) ds self.calc_score(self.dealer_cards) self.state State.GAME_OVER self.btn_start.setEnabled(True) if ps 21: result 爆牌你输了 elif ds 21: result 庄家爆牌你赢了 elif ps ds: result f你赢了 {ps}:{ds} elif ps ds: result f你输了 {ps}:{ds} else: result f平局 {ps}:{ds} self.info.setText(result) self.update_display()这里没有处理“黑杰克”特殊奖励因为课设通常不需要赔率。如果老师要求可以加一个is_blackjack函数判断手牌是否正好两张且点数等于21然后单独给结果字符串加上“Blackjack”标记。把判定写成独立函数还有一个好处未来想加统计分析直接调用它跑一万局也不污染界面代码。表格可以总结判定优先级条件结果玩家点数 21玩家爆牌庄家胜庄家点数 21庄家爆牌玩家胜玩家点数 庄家点数玩家胜玩家点数 庄家点数庄家胜玩家点数 庄家点数平局5. 课设翻车现场Qt版本、编码、布局与随机数的坑写这个项目时我踩过的坑比代码行数还多。这里挑五个最常见的每个都是“现象→原因→解决”的结构你在自己的环境里大概率也会遇到。5.1 现象中文按钮文字变成乱码打开程序按钮上显示“å¼å§”这样的乱码牌面的花色和A也乱。原因是源码文件本身是GBK编码但Qt解释器按UTF-8读取或者反过来。Windows下用记事本保存的默认编码是GBK而Qt Creator通常按UTF-8。解决所有源码文件统一用UTF-8保存。在文件顶部加# -*- coding: utf-8 -*-并且用Qt Creator的“保持文件编码”选项固化UTF-8。如果用了中文字符串最好写成QStringLiteral(开始)C或普通字符串Python前提是文件是UTF-8。最暴力的方法是不在代码里写中文字面量改用tr(Start)配合翻译文件但课设没必要。5.2 现象狂点“要牌”按钮导致程序卡死玩家快速连点“要牌”程序突然没响应或者报IndexError: pop from empty list。原因是按钮每次点击都会触发hit()但hit()里没有及时禁用按钮。比如玩家一回合连点三次第一次正常第二次手牌加了一张第三次可能已经爆牌进入GAME_OVER但状态还没更新第四次再点就去pop()空牌堆了。解决在hit()开头把btn_hit置为False进hit()后立即禁用。并且hit()第一行检查状态如果状态不是PLAYER_TURN就直接返回。这样即使按钮连着点击后面的调用也会被状态检查拦截。5.3 现象窗口被拉伸后牌面标签挤成一团拖动窗口变宽手牌标签还是挤在左边或者变窄时牌和牌叠在一起。原因是QLabel没有设置大小策略布局默认按内容尺寸排列。拉伸窗口时布局会把多余空间分给可伸缩的控件但QLabel默认不可伸缩。解决给牌标签设置setMinimumWidth(40)和setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)或者用setStretchFactor给牌布局设置伸缩系数。更省事的是直接给每个标签设固定宽度但窗口太小时会截断所以建议用minimum加Expanding。这样窗口变大时牌与牌之间均匀分散变小时保持最小宽度。5.4 现象每次运行程序发牌顺序一模一样重新启动程序后第一次发的牌永远是♠A、♦10这样的固定顺序。原因是为了调试方便代码里写了一行random.seed(42)然后忘了删。固定种子会让shuffle()每次产生相同的排列。解决正式运行时不要调用random.seed()让它使用系统时间作为种子。如果想测试特定布局可以临时加固定种子测试完务必注释掉。还可以用random.SystemRandom()来取系统真随机数但洗牌算法用random.randint足够了。5.5 现象A的算分逻辑在特定手牌下报错手牌A A 9 3时程序算成31然后就显示爆牌但规则里应该把A算1得到21。原因是计分的while循环只降了一次A。A A 9 3初始是11119334减一次10变成24还没降到21循环却退出了。解决while aces 0 and score 21循环体会持续把A从11降为1直到总分不大于21或者所有A都用完。上面第4章的代码就是正确写法。另一个容易错的地方是解析10时我最初用card[0]取点数结果♠10取到的是♠正确做法是card[1:]。6. 让牌桌更好看一点自绘牌面与简单动画课设答辩时界面是门面。如果你不想停留在“用QLabel显示文字”的朴素风格可以用QPainter自己画牌面再给发牌动作加一点动画整套程序立刻上升一个档次。更关键的是把界面和逻辑彻底拆开后你还能做自动化测试——这比画牌更重要。6.1 用QPainter画一张牌写一个CardWidget继承QWidget重写paintEvent在里面画白色圆角矩形左上角和右下角画点数中央画花色。这样每张牌都是一个可独立刷新的控件。from PySide6.QtGui import QPainter, QColor, QFont, QBrush from PySide6.QtCore import Qt class CardWidget(QWidget): def __init__(self, card): super().__init__() self.card card self.setFixedSize(70, 110) def paintEvent(self, event): p QPainter(self) p.setRenderHint(QPainter.RenderHint.Antialiasing) p.setBrush(QBrush(QColor(white))) p.drawRoundedRect(0, 0, 69, 109, 10, 10) p.setPen(QColor(black)) p.setFont(QFont(Arial, 14, QFont.Weight.Bold)) # 花色与点数 rank self.card[1:] suit self.card[0] p.drawText(10, 20, rank) p.drawText(45, 95, rank) p.drawText(28, 60, suit)参数说明setFixedSize固定牌尺寸布局时就按这个大小排。paintEvent里所有绘制都用像素坐标坐标系的起点是控件左上角。drawRoundedRect的圆角半径参数一般设为宽度的六分之一左右看起来自然。6.2 发牌动画让牌从牌堆滑到目标位置纯静态界面也能交差但加上动画会让人眼前一亮。用QPropertyAnimation让CardWidget的位置在一定时间内从起点平移到终点注意这个动画只能在布局完成后启动否则坐标会被布局管理覆盖。from PySide6.QtCore import QPropertyAnimation, QEasingCurve, QPoint def animate_card(self, widget, target_pos): anim QPropertyAnimation(widget, bpos) anim.setDuration(300) anim.setStartValue(QPoint(0, 0)) anim.setEndValue(target_pos) anim.setEasingCurve(QEasingCurve.Type.OutCubic) anim.start()这个技巧的关键是先把CardWidget加入布局布局计算出它的最终位置后再立刻用pos()获取目标坐标然后启动动画。否则动画会把控件挪到奇怪的地方。动画结束后控件位置由布局接管后续窗口拉伸也不会乱。6.3 验证逻辑把界面拆掉只测游戏规则最后分享一个我个人的血泪习惯做课设时游戏逻辑和界面一定要分层。我写这个21点程序时先把Game类单独拉出来写了一个不依赖任何Qt控件的纯Python测试脚本模拟跑一万局游戏统计爆牌率和庄家胜率。逻辑验证通过后再套界面界面出问题时只需要调布局和信号不用看游戏规则。# test_game.py 单独运行不导入任何Qt模块 def test_blackjack_rule(): game Game() game.build_deck() game.shuffle() game.player_cards [♠A, ♦K] # 黑杰克 assert game.calc_score(game.player_cards) 21 # 模拟10000局检查庄家停牌点数至少为17 for _ in range(10000): g Game() g.build_deck() g.shuffle() g.player_cards [g.deck.pop(), g.deck.pop()] g.dealer_cards [g.deck.pop(), g.deck.pop()] # 玩家直接停牌庄家自动要牌 g.dealer_turn() assert g.calc_score(g.dealer_cards) 17从那以后我每次做完一个带界面的小项目都会强制自己先跑一遍逻辑层的无界面测试再打开窗口手动点击。这样做不是形式主义而是把“运行一下看起来能玩”变成“每个状态转移都被验证过”。这套习惯帮我避开了无数次答辩现场的鬼畜Bug也希望帮到你。本文还有配套的精品资源点击获取