基于Python的升级扑克牌游戏规则引擎与状态机实现 简介这是一份面向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 不是为了赶时髦是因为slotsTrue的 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..1414 代表 A dataclass(frozenTrue, slotsTrue) class Card: suit: Suit rank: int # 2-14 普通牌15 小王16 大王 property def is_joker(self) - bool: return self.suit is Suit.JOKERfrozenTrue是最关键的一行。牌在整局里不该被改谁改谁出错冻结之后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..1414 即 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 以上即可低于这个版本slotsTrue和match会直接报语法错。依赖装不装都行装了就多两个便利。python --version # 确认 3.10 pip install -r requirements.txt # 可选rich / pytest python main.py --seed 42 --level 2 --players human,ai,ai,ai参数不多但每个都有实际用途参数默认值说明--seedNone固定洗牌种子复现同一局牌用不传就每次都不一样--level2双方起始级牌从 2 打到 A--playershuman,ai,ai,ai四个座位的人机配置座位 0 和 2 一队1 和 3 一队--autoFalse四个座位全换成 AI不打交互直接跑完压测和回归用--trumpask主花色决定方式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不用写特判。级牌不是中间值的时候比如打 Arank 14全部走rank - 1A 自己走 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、本文还有配套的精品资源点击获取