简介:这是一份面向Python初学者的《升级》扑克牌游戏完整实现资源,适合希望通过一个真实小项目巩固语法、理解面向对象设计并入门游戏开发的读者。项目把牌面、牌堆、玩家、裁判等角色抽象为独立类,覆盖洗牌发牌、牌型比较、AI出牌决策与规则判定等核心逻辑,同时提供可交互的图形界面,涉及界面库使用、按钮事件绑定与回调函数编写;描述中还提到异常处理、模块拆分与unittest测试,便于读者对照源码理解工程化写法与常见错误防范思路。资源包共67个文件、约2.37MB,以58张jpg牌面与背景图片为素材主体,另有5个py脚本承载界面与对局逻辑、2个txt说明文档、1个pdfreadme及1个内嵌zip,结构清晰便于按模块阅读。该资源已有3948人学习,适合按图索骥地拆解一个可运行的小型游戏项目。
1. 从一副洗好的牌到打过 A,这套 Python 扑克牌游戏能跑成什么样
四个人凑不齐的时候,我一般还是想自己造一个对手。这套用 Python 写的《升级》扑克牌游戏,就是按这个思路搭起来的:两副牌 108 张、四人两两组队、从 2 一路打到 A,亮主、反主、扣底、跟牌、甩牌、毙牌、抓底分翻倍这些环节一个不落,全部跑在命令行里,python main.py敲下去就能开一局。它没有图形界面,也没做动画,价值在于把一整套地方规则拆成了能读、能改的代码:牌力怎么比、跟牌什么时候合法、闲家吃够 80 分怎么把庄家拉下台,每一条都能在源码里指到具体函数。适合两类人——想写自己第一套牌类引擎的初学者,和想把规则层单独抽出来接 Web 前端或强化学习环境的老手。
2. 目录拆开看:四层结构怎么把一副 108 张牌管住
2.1 分层:表示、规则、状态、AI 各管一摊
牌类游戏最容易被写成一个大while循环,几百行if塞在一个文件里,第一周能跑,第三周加一条「甩牌罚则」就要推翻重写。这个项目一开始就按四层切开,判断标准很简单:一层里的改动,不应该逼着另一层跟着改。
upgrade/ ├── main.py # 入口:解析参数、组装对象、启动循环 ├── config.py # 规则开关(是否允许反无主、罚则方式、翻倍倍数) ├── models/ │ ├── card.py # Card / Suit / 点数常量 │ └── deck.py # 造牌、洗牌、发牌 ├── rules/ │ ├── trump.py # 主牌判定与权重计算(纯函数) │ ├── pattern.py # 牌型识别:单张 / 对子 / 甩牌 │ └── compare.py # 大小比较、毙牌判定 ├── engine/ │ ├── game.py # Game 类,持有全部状态 │ ├── phase.py # 阶段枚举与推进 │ └── settle.py # 算分、底牌翻倍、升级 ├── ai/ │ ├── base.py # AI 抽象接口 │ └── greedy.py # 启发式出牌 ├── ui/ │ └── cli.py # 只负责输入输出,不碰规则 └── tests/ ├── test_trump.py └── test_pattern.pyrules/这一层是纯函数,输入牌和上下文,输出布尔值或整数,不持有任何状态。它的好处在后面很显眼:写完weight()之后,可以拿一百组手牌样本直接跑单测,不用起一局游戏。engine/game.py只调rules/,不知道外面是命令行还是浏览器;ui/cli.py只负责把状态渲染成人能看的字符串,一行规则判断都不许写。这条边界我踩过坑,早期图省事在 UI 里判了「这手牌能不能出」,后来换界面的时候规则全丢,只能重写。
技术选型上刻意保持了极简:Python 3.10+,零第三方运行时依赖。用 3.10 不是为了赶时髦,是因为slots=True的 dataclass 和match语句在这套代码里确实顺手——前者让几十万次比较的对象创建开销降下来,后者让阶段推进的step()写得很整。可选依赖只有两个:rich用来给终端上色,pytest用来跑测试,装不装都不影响主流程跑通。
2.2 数据模型:一张牌该存什么、不该存什么
先看Card。它只存两个字段:花色和点数,其他什么都不存。
# models/card.py from dataclasses import dataclass from enum import Enum class Suit(Enum): SPADE = "♠" HEART = "♥" CLUB = "♣" DIAMOND = "♦" JOKER = "★" # 王单独占一个花色,避免 None 判断满天飞 BIG_JOKER = 16 SMALL_JOKER = 15 LEVELS = list(range(2, 15)) # 2..14,14 代表 A @dataclass(frozen=True, slots=True) class Card: suit: Suit rank: int # 2-14 普通牌,15 小王,16 大王 @property def is_joker(self) -> bool: return self.suit is Suit.JOKERfrozen=True是最关键的一行。牌在整局里不该被改,谁改谁出错;冻结之后Card可以安全地哈希、放进set、被多个玩家对象共享引用,也不用担心发牌时浅拷贝导致「两副牌变一副」。rank用整数 2 到 14 而不是'2'、'K'、'A'这种字符串,是为了省掉一张字符映射表,比较大小直接比数字,14当 A 用。大小王用 15 和 16 接管了 14 之上的两个档位,判断时不用再额外看花色。
这里有一条设计红线:Card里不存「是不是主牌」「权重多少」。主牌身份是随每局级牌和主花色动态变化的,打 2 的时候 ♠2 是主,下一局打 7,♠2 就变回普通副牌。把这个状态写进对象,就等着过期数据引发的玄学 bug 吧。所有和局面相关的属性一律由rules/trump.py现算。
造牌也简单,两副标准牌加四个王:
# models/deck.py import random from models.card import Card, Suit, BIG_JOKER, SMALL_JOKER def build_deck() -> list[Card]: deck = [] for _ in range(2): # 两副牌 for suit in (Suit.SPADE, Suit.HEART, Suit.CLUB, Suit.DIAMOND): for rank in range(2, 15): # 2..14,14 即 A deck.append(Card(suit, rank)) deck.append(Card(Suit.JOKER, SMALL_JOKER)) deck.append(Card(Suit.JOKER, BIG_JOKER)) return deck # 一共 108 张 def deal(deck: list[Card], players: int, rng: random.Random) -> list[list[Card]]: rng.shuffle(deck) per = (len(deck) - 8) // players # 留 8 张底牌 hands = [deck[i * per:(i + 1) * per] for i in range(players)] return handsrange(2, 15)生成 2 到 14 共 13 个点数,乘 4 门花色再乘 2 副,加 4 张王,正好 108。减掉 8 张底牌后每人 25 张,这个数字对不对,可以拿assert钉死在测试里,比自己心算靠得住。rng从外部传进来而不是在函数里random.shuffle,是为了固定种子后能完整复现一局牌,调试时能省很多时间。
2.3 跑起来:环境、启动参数与第一次对局
环境上没有特殊要求,Python 3.10 以上即可,低于这个版本slots=True和match会直接报语法错。依赖装不装都行,装了就多两个便利。
python --version # 确认 >= 3.10 pip install -r requirements.txt # 可选:rich / pytest python main.py --seed 42 --level 2 --players human,ai,ai,ai参数不多,但每个都有实际用途:
| 参数 | 默认值 | 说明 |
|---|---|---|
--seed | None | 固定洗牌种子,复现同一局牌用;不传就每次都不一样 |
--level | 2 | 双方起始级牌,从 2 打到 A |
--players | human,ai,ai,ai | 四个座位的人机配置,座位 0 和 2 一队,1 和 3 一队 |
--auto | False | 四个座位全换成 AI,不打交互直接跑完,压测和回归用 |
--trump | ask | 主花色决定方式,ask走亮主流程,指定花色则跳过亮主 |
第一次跑建议先开--auto --seed 42,看它能不能从头到尾打完一局不崩。跑通之后再换成human,ai,ai,ai自己上手。终端里手牌是按主牌、副牌分两行渲染的,主牌会排在最前面并按权重降序,这个排序逻辑在ui/cli.py的render_hand()里,调rules/trump.weight()拿权重再sorted,成本很低,但对读牌体验影响极大——早期版本不分主副,一局打完我眼睛都花了。
3. 规则引擎拆解:主牌权重、跟牌校验和甩牌罚则
3.1 主牌权重:把「级牌最大」压成一个整数
整个规则层的地基是一个函数:给定一张牌和当前局面,返回它的权重。有了权重,大小比较就退化成两个整数比大小,毙牌、甩牌、跟牌全都跟着简单。
主牌的大小顺序按这套来排(以打 5、主花色黑桃为例):大王 > 小王 > ♠5(主级牌)> ♥5/♣5/♦5(三张副级牌,彼此同权)> ♠A > ♠K > ... > ♠6 > ♠4 > ... > ♠2。注意 ♠5 被单独抽到最上面一档,普通主牌序列里要跳过它。
# rules/trump.py from models.card import Card, Suit, BIG_JOKER, SMALL_JOKER BIG_TIER = 1000 def is_trump(card: Card, trump_suit: Suit, level_rank: int) -> bool: """王、主花色、任意花色的级牌,都是主牌""" return (card.suit is Suit.JOKER or card.suit is trump_suit or card.rank == level_rank) def rank_index(rank: int, level_rank: int) -> int: """把 2..14 压成 1..12 的相对序号,级牌那一档被跳过""" return rank - 1 if rank < level_rank else rank - 2 def weight(card: Card, trump_suit: Suit, level_rank: int) -> int: """主牌一律 >= 900,副牌 1..12,两者永远不会撞车""" if card.rank == BIG_JOKER: return BIG_TIER + 3 # 1003 大王 if card.rank == SMALL_JOKER: return BIG_TIER + 2 # 1002 小王 if card.rank == level_rank: # 主级牌比所有副级牌大;三张副级牌同权,不区分花色 return BIG_TIER + 1 if card.suit is trump_suit else BIG_TIER if card.suit is trump_suit: return 900 + rank_index(card.rank, level_rank) # 901..912 return rank_index(card.rank, level_rank) # 1..12,只在同花色内比较为什么主牌用 900 起步而不是紧接着副牌的 1..12 往后排?因为留出的隔离带能兜住规则变化。哪天加密规则变成「级牌也可以被加倍」,或者加个「主花色副牌」的特殊档位,在 900 和 1000 之间塞数字就行,不用担心把副牌的顺序挤乱。这个隔离带是我在改第三版规则时补上的,之前主副牌连着排,加一条规则就得重算一遍所有偏移量,很麻烦。
rank_index是这段代码的核心技巧。打 5 的时候,♠A 算出来是14 - 2 = 12,♠6 是6 - 2 = 4,♠4 是4 - 1 = 3。中间那个 5 被顺手跳掉了,主牌序列自然变成连续的 1 到 12,不用写特判。级牌不是中间值的时候(比如打 A),rank < 14全部走rank - 1,A 自己走 14 那一档但会被前面的level_rank判断截走,逻辑依然自洽。
副牌之间其实不能互相比较,♠A 打不过 ♥2,因为它们根本不在同一门里。所以副牌权重算出来只有 1 到 12,够同花色内部比大小就行。真要比的时候必须先确认同花色,这个约束放在比较函数里卡死:
# rules/compare.py from models.card import Card, Suit from rules.trump import is_trump, weight def beats(a: Card, b: Card, trump_suit: Suit, level_rank: int) -> bool: """a 是否严格大于 b;不同门的副牌直接抛异常,绝不静默返回 False""" ta = is_trump(a, trump_suit, level_rank) tb = is_trump(b, trump_suit, level_rank) if ta != tb: return ta # 主牌压副牌 if a.suit is not b.suit and not ta: raise ValueError(f"不同花色的副牌不可比较: {a} vs {b}") return weight(a, trump_suit, level_rank) > weight(b, trump_suit, level_rank)这里故意让它抛异常而不是返回False。「不同副牌不能比」是调用方的逻辑错误,静默返回False会把 bug 藏到很后面的算分环节才暴露,那时候排查成本翻十倍。宁可早期炸掉。
3.2 牌型识别与跟牌合法性
《升级》里的牌型比斗地主少,主要就三种:单张、对子、甩牌(同门多张一起出)。连对、拖拉机这类可以由甩牌覆盖,本项目默认不开,config.py里留了开关。
# rules/pattern.py from enum import Enum from models.card import Card, Suit from rules.trump import is_trump class Pattern(Enum): SINGLE = "单张" PAIR = "对子" THROW = "甩牌" def is_pair(cards: list[Card], trump_suit: Suit, level_rank: int) -> bool: """同花色同点数才算对子;两张副级牌花色不同不算""" a, b = cards return a.suit is b.suit and a.rank == b.rank def classify(cards: list[Card], trump_suit: Suit, level_rank: int) -> Pattern: if len(cards) == 1: return Pattern.SINGLE if len(cards) == 2 and is_pair(cards, trump_suit, level_rank): return Pattern.PAIR return Pattern.THROW # 多张同门,合法性交给甩牌校验对子判定看着简单,坑在于级牌。打 5 的时候,♥5 和 ♣5 都是主牌,但花色不同,不算对子;♠5 和 ♠5(两副牌里确实有两张)才算。另外两张小王在多数地区的规则里算对子,两张大王也算,但大小王混搭不算。is_pair用「同花色同点数」一条规则统一处理,王因为suit都是JOKER、rank分别是 15 和 16,同种王天然成对,混搭天然不成对,不用写特判。这就是前面把王塞进Suit枚举的回报。
跟牌校验是整局里调用最频繁的函数,每个玩家每次出牌都要过一遍。规则本身不复杂:首家出副花色,跟牌者只要有这门的牌就必须出这门,张数也得一样;一门都没有,才可以随便垫。麻烦的是「有这门的牌但不够张数」这种情况——比如首家出对子,你手里只有一张这门牌,那就必须把这一张出了,剩下的用别的补。所以判定要分「完全没有」和「有但不够」两种。
def effective_suit(card: Card, trump_suit: Suit, level_rank: int) -> Suit: """主牌统一归到 JOKER 这一门,跟牌时不用分情况讨论""" return Suit.JOKER if is_trump(card, trump_suit, level_rank) else card.suit def validate_follow(hand: list[Card], lead: list[Card], trump_suit: Suit, level_rank: int) -> tuple[bool, str]: """返回 (是否合法, 不合法原因);hand 是跟牌者的完整手牌""" need = len(lead) if len(hand) < need: return False, "手牌数量不足" lead_suit = effective_suit(lead[0], trump_suit, level_rank) same = [c for c in hand if effective_suit(c, trump_suit, level_rank) is lead_suit] if not same: return True, "" # 一门都没有,随便垫 if len(same) < need: return True, "" # 有但不够,出完这门的全部即可 return True, ""effective_suit这个函数是让代码变短的关键。主牌本来混着四门花色加王,如果跟牌时给主牌单独写一套分支,函数会膨胀到三十行。把所有主牌统一映射成Suit.JOKER这一门之后,跟牌逻辑完全不用关心主副,一视同仁。用JOKER做哨兵值有点脏,更干净的做法是定义一个独立的TRUMP = object()常量,但那样effective_suit的返回类型就不是Suit了,类型标注要跟着放宽。权衡之下我选了前者,在函数 docstring 里写清楚这个约定就行。
真正落地时还要补一层:查完「够不够」之后,得确认玩家实际出的牌确实是从same里挑的,不能嘴上说着跟牌手里出别的门。这个校验放在Game层做,拿着hand和实际出的cards做差集,rules/保持纯函数不碰状态。
3.3 甩牌与毙牌:什么时候罚、罚哪张
甩牌是《升级》里最容易出争议的地方。规则大意是:首家可以一次甩出同一门的多张牌,前提是这几张牌在当前场上每一张都是最大的,只要有一张能被别人压住,甩牌就不成立。有些地方的规则是「不成立就罚出最小的一张,其余收回」,有些地方是「整手作废,只能出最小的一张」,本项目默认前者,罚则方式在config.py里可切。
def validate_throw(cards: list[Card], table: list[Card], trump_suit: Suit, level_rank: int) -> tuple[bool, Card | None]: """table 是本轮其他玩家已经出的牌;返回 (是否成立, 被压住的那张)""" if not cards: return False, None suit = effective_suit(cards[0], trump_suit, level_rank) if any(effective_suit(c, trump_suit, level_rank) is not suit for c in cards): return False, None # 不同门,连甩都谈不上 for c in cards: for t in table: if effective_suit(t, trump_suit, level_rank) is not suit: continue if weight(t, trump_suit, level_rank) > weight(c, trump_suit, level_rank): return False, c # 找到第一张被压的,直接返回 return True, Nonetable参数只包含已经出过的牌,还没轮到的人手里有什么,程序不该去偷看。这一点在写 AI 的时候要特别小心,很容易顺手把完整手牌传进去,结果 AI 打得跟开挂一样,测试时看着挺正常,真人对局就露馅了。
毙牌是另一条路径:跟牌者一门花色都没有,用主牌去压。要求是全部用主牌,而且张数要跟首家一致。半主半副混着出属于垫牌,压不住任何东西。
def can_ruff(follow: list[Card], lead: list[Card], trump_suit: Suit, level_rank: int) -> bool: """判断这手牌是不是有效的毙牌""" if len(follow) != len(lead): return False if any(not is_trump(c, trump_suit, level_rank) for c in follow): return False # 有副牌混入,不算毙 if len(lead) == 2 and not is_pair(follow, trump_suit, level_rank): return False # 首家出对子,毙牌也得是对子 return True最后那条对子判断容易被漏。首家出对子,跟牌者想毙,必须用一对主牌毙,用一张大王加一张小王不行,用两张不同花色的副级牌也不行。规则上说得通,代码里如果只判了len和is_trump,就会把这些非法组合放过去。这个 bug 我在测试里抓到的,写规则的时候觉得「谁会这么出」,结果 AI 真的这么出了。
4. 状态机与 AI:一局牌从发牌到升级的完整推进
4.1 五个阶段与状态推进
一局《升级》的生命周期可以切成五段:发牌、亮主、扣底、出牌、结算。每段的合法操作完全不同,用枚举把阶段显式建模出来,比用一堆布尔标志位清爽得多。
# engine/phase.py from enum import Enum, auto class Phase(Enum): DEAL = auto() # 发牌,边发边可亮主 BOTTOM = auto() # 庄家扣底牌 PLAY = auto() # 出牌,循环 25 轮 SETTLE = auto() # 算分、升级、判断是否结束Game类持有全部状态,对外只暴露一个step()。所有交互——不管是命令行敲的、AI 算的、还是将来 WebSocket 推来的——最终都收敛成对step()的调用,状态机自己判断当前阶段该干什么。
# engine/game.py from engine.phase import Phase from models.deck import build_deck, deal class Game: def __init__(self, players, level_ranks: dict[int, int], rng): self.players = players # 长度 4 的列表 self.level = level_ranks # {0: 2, 1: 2} 两支队伍各自的级牌 self.rng = rng self.dealer_team = 0 # 庄家队伍编号 self.bottom: list = [] # 底牌 8 张 self.tricks: list = [] # 每轮结果 [(赢家座位, 该轮所有牌)] self.phase = Phase.DEAL deck = build_deck() hands = deal(deck, 4, rng) for p, h in zip(players, hands): p.hand = h self.bottom = deck[-8:] def step(self): match self.phase: case Phase.DEAL: self._start_bid() case Phase.BOTTOM: self._ask_dealer_bury() case Phase.PLAY: self._play_one_trick() case Phase.SETTLE: self._settle()match在这里读起来像伪代码,但每一行都是真的可执行分支。这种写法在阶段固定的状态机里比if/elif链清楚太多,加阶段也就是加一个case。要说限制,就是 Python 版本必须 3.10+,对老环境不友好。如果非要在 3.8 上跑,把match换成字典映射{Phase.DEAL: self._start_bid, ...}也完全等价,只是调试时栈信息没那么直观。
出牌阶段每轮循环四次,从上一轮的赢家开始,顺时针各出一手,然后比大小定赢家。这一轮如果出了分牌(5、10、K),分记给赢家所在队伍——前提是赢家是闲家,庄家赢的轮次只算「守住」,不产生分数。
def _play_one_trick(self): leader = self.leader # 上一轮赢家,首轮是庄家 table, plays = [], [] for i in range(4): seat = (leader + i) % 4 cards = self.players[seat].choose(self.public_view(), table) table.extend(cards) plays.append((seat, cards)) winner = self._judge_trick(plays) # 返回座位号 self.tricks.append((winner, table)) self.leader = winner if len(self.tricks) == 25: self.phase = Phase.SETTLE注意choose()的入参只有public_view()和table,拿不到别人的手牌。这个签名约束比在函数里写注释靠谱,AI 想作弊都没有路。
4.2 亮主与扣底:最容易写出歧义的两步
亮主规则各地差异最大,本项目取最常见的一套:发牌过程中,玩家拿当前本方级牌亮主,用一张亮算普通亮主,用两张同花色同点数的级牌亮算「加固」;后面的人想反主必须比前面强,一张反不了加固过的,两张可以;反成无主需要四张王。
def bid(self, seat: int, cards: list[Card], trump_suit: Suit) -> bool: """seat 用 cards 亮 trump_suit 为主""" hand = self.players[seat].hand if not all(c in hand for c in cards): return False # 手里没有,作弊拦截 my_level = self.level[self.team_of(seat)] if any(c.rank != my_level for c in cards): return False # 只能用本方级牌亮 if len(cards) == 2 and not is_pair(cards, trump_suit, my_level): return False # 两张必须成对 strength = len(cards) # 1 普通 / 2 加固 if self.bid_state and strength <= self.bid_state.strength: return False # 反主必须更强 self.bid_state = BidState(seat, trump_suit, strength, cards) return Truestrength只用 1 和 2 两个值,看起来太粗糙,但够用了。真实规则里还有「四张王反无主」这种情况,把它当成strength = 4的特例塞进去即可,判定顺序不用改。我一开始设计过更复杂的「亮主强度评分」,把花色权重、时机都算进去,后来发现规则本身没那么细,纯属过度设计。
扣底是庄家的动作:拿走 8 张底牌进手,再从手里扣 8 张出来。扣下去的分牌(5、
本文还有配套的精品资源,点击获取