简介:这是一份基于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,初始分数是11+11+9=31,第一次降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初始是11+11+9+3=34,减一次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, b"pos") 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,也希望帮到你。
本文还有配套的精品资源,点击获取