ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Python实现《升级》扑克牌游戏:牌面建模、状态机与规则校验

Python实现《升级》扑克牌游戏:牌面建模、状态机与规则校验

简介:《升级》扑克牌游戏——Python实现是一份面向Python初学者与进阶学习者的项目型代码包,适合通过完整纸牌游戏理解基础语法、面向对象设计与游戏逻辑。压缩包共67个文件,约2.37MB,包含58张jpg牌面与界面素材、5个py源代码、2个txt说明、1份pdf文档及1个嵌套zip,素材与脚本配合便于直接运行和二次修改。已有3948人学习,说明该案例具备一定参考热度。内容围绕牌、牌堆、玩家与游戏流程建模,涉及洗牌发牌、出牌判定、裁判监督、异常处理、模块拆分与测试调试,也讨论图形界面和事件回调、AI玩家决策等进阶主题,可用于课程设计、毕业项目参考或Python综合练习。

1. 从一副牌到一局《升级》:为什么值得用 Python 重写一遍

两副牌、四个座位、一门主、一条级牌线——《升级》看着只是比大小,写起来全是状态。我拆的这份 Python 扑克牌游戏项目,目标很朴素:在命令行里把一局《升级》完整跑完,发牌、亮主、扣底、跟牌、算分,一步不落。它不适合当纯练手玩具,适合已经会写类、会用字典搭状态机,想找一个规则够碎、边界够清的人。规则越琐碎,越能照出数据建模的功夫,牌桌上那些“我觉得应该这样”的地方,最后都会变成一行 assert。下面按我拆解的顺序走,从牌怎么编码开始。

2. 牌面建模:108 张牌怎么编码才不会在比大小时翻车

整份代码里最容易推倒重来的不是算法,是牌的表示。牌一旦编错,后面排序、判对子、认拖拉机全是连锁反应,改一处就要重跑十处。所以第一章先把地基打稳:牌长什么样、怎么比大小、怎么判断两张牌是不是同一门。

2.1 用 (花色, 点数) 做主键,两副牌必须容忍重复

第一反应往往是用自增 id 或者"S2"、"H10"这种字符串拼牌,图省事。前者的问题是一张牌的语义被藏进了一个数字里,打印调试时全靠猜;后者看着能用,但"S10"和"S1"的字典序会和你的直觉打架,排序时直接翻车。更稳妥的做法是元组(suit, rank),花色和点数分开存,比较、分组、序列化都不别扭。

两副牌一共 108 张:52 张普通牌各两张,大小王各两张。这意味着同一个键会出现两次,任何用set去重的写法都会把一副牌悄悄吃掉一半。计数一律用Counter,判重一律用长度比对,别图快。

# cards.py —— 牌面与排序,全篇复用的基础层 from itertools import product from collections import Counter SUITS = ("S", "H", "D", "C") # 黑桃 红桃 方块 梅花 RANKS = ("2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K", "A") SUIT_ORDER = {"S": 3, "H": 2, "D": 1, "C": 0} SEQ = {r: i for i, r in enumerate(RANKS)} # 2 -> 0 ... A -> 12 def build_deck(decks: int = 2): """两副牌共 108 张:52 * 2 普通牌 + 大小王各 2 张""" deck = [(s, r) for s, r in product(SUITS, RANKS) for _ in range(decks)] deck += [("JOKER", "BJ")] * decks deck += [("JOKER", "SJ")] * decks return deck

decks这个参数不是摆设。想跑三人一副牌的简化版,把它改成 1 就行,发牌张数那层再按人数分流。SUIT_ORDER单独抽出来,是因为副牌区里同点数的不同花色需要有个稳定顺序,不能依赖字典遍历的顺序,那玩意儿在不同 Python 版本里表现不一样,属于典型的玄学来源。

2.2 大小比较:把王、正主、副主、主花色压进一个三元组

《升级》的大小顺序是分层的:大王 > 小王 > 正主(主花色的级牌)> 副主(另外三门的级牌)> 主花色普通牌 > 副牌。打 2 的时候,一张梅花 2 在主牌区里能压过黑桃 A,这个反直觉的设定如果写成十几条if-elif,后面加规则、改规则时一定会漏。

我一般会把每张牌换算成一个可比较的元组,交给 Python 原生的元组比较去处理。这样排序、判最大、找同伴,全是一行max()或者sorted(key=...),不用自己维护分支。

def card_key(card, level_rank: str, trump_suit: str): """ 返回可直接比较的三元组,越大越强。 第 0 位:3 = 王 2 = 主牌区 1 = 副牌区 第 1 位:主牌区内 2 = 级牌 0 = 主花色普通牌;副牌区 = 点数序号 第 2 位:同级内部的细分(正主/副主、花色顺序) """ suit, rank = card if rank == "BJ": return (3, 2, 0) if rank == "SJ": return (3, 1, 0) if rank == level_rank: # 正主压副主,副主之间按固定花色序排 return (2, 2 if suit == trump_suit else 1, SUIT_ORDER[suit]) if suit == trump_suit: return (2, 0, SEQ[rank]) return (1, SEQ[rank], SUIT_ORDER[suit])

三个位置各有分工。第 0 位只区分“王 / 主 / 副”三大区,保证跨区比较时结果绝对正确;第 1 位在主牌区里再切一层,把级牌抬到主花色普通牌之上;第 2 位兜底,处理级牌之间和同区内部的先后。level_rank和trump_suit必须作为参数传进来,不能写成全局变量——同一份代码要支持“连打多局、每局级牌递增”,一旦写死,第二局就得重启进程。这也是我看过的练习代码里最高频的设计缺陷。

2.3 序列表与手牌分组:级牌必须从连续序列里挖掉

比大小解决了,接下来是连号。拖拉机(连对)判定依赖的是“同一门花色里点数是否连续”,而《升级》里级牌是被抽出来当主牌的,它不参与原来的序列。打 3 的时候,一门花色的实际顺序是 A K Q J 10 9 8 7 6 5 4 2——3 被挖走,2 掉到了最小。这个细节不处理,一对 4 加一对 2 会被误判成连对。

def seq_of(card, level_rank: str): """牌在本门连续序列里的位置;级牌与王不参与连号,返回 None""" suit, rank = card if suit == "JOKER" or rank == level_rank: return None return SEQ[rank] def group_of(card, level_rank: str, trump_suit: str): """把牌归到“跟牌门”:主牌统一算一门,其余按花色""" suit, rank = card if suit == "JOKER" or rank == level_rank or suit == trump_suit: return "TRUMP" return suit

seq_of返回None是一种“主动制造边界”的写法:只要序列里出现None,连号判定立刻中断,不需要额外判断。group_of则是跟牌规则的基础——跟牌看的是“门”而不是“花色”,主牌不分花色,全都算一门,这点和很多人的直觉相反。手牌在内存里我建议直接按group_of分桶缓存一份,每次出牌后增量更新,比每次遍历 25 张牌重新分组快得多,也方便给玩家做“你这门有几张”的提示。

3. 状态机:发牌、亮主、扣底怎么串成一局

牌能比大小之后,下一步是让一局游戏能往前走。我的做法是把整局压缩成一个字典,用阶段字段驱动流程,谁在什么阶段能做什么,检查得清清楚楚。这样写的好处是存档、回放、单元测试都只要拿这个字典做快照,不用去动对象内部。

3.1 状态字典:一局游戏只有四个阶段

四个阶段是deal(发牌)、bid(亮主)、bury(扣底)、play(出牌)。看着简单,但每个阶段之间的数据交接很容易散架:庄家要先收底牌再扣,扣完底牌张数必须回到 25,亮主之前不能扣底。把这些不变量写进状态里,后面出问题才查得动。

# engine.py —— 把一局拆成四个阶段 import random STAGES = ("deal", "bid", "bury", "play") def new_state(level_rank="2", dealer=0, seed=None): return { "level": level_rank, # 当前打几 "trump_suit": None, # None 表示还没亮主 "dealer": dealer, # 庄家座位 0~3,0/2 一队,1/3 一队 "stage": "deal", "hands": [[], [], [], []], "bottom": [], # 底牌 8 张 "trick": [], # 当前这一轮四家出的牌 "points": [0, 0], # [庄家方, 闲家方] "rng": random.Random(seed), } def deal(state): deck = build_deck() state["rng"].shuffle(deck) for seat in range(4): # 每人 25 张 state["hands"][seat] = deck[seat * 25:(seat + 1) * 25] state["bottom"] = deck[100:] # 余下 8 张做底 state["stage"] = "bid" return state

seed是我在这份代码里最看重的一个参数。不传就是真随机,传了就是可复现,调规则的时候没有它根本没法定位问题——同一个 bug 打十局才复现一次,谁受得了。hands用列表而不是集合,因为一手牌里天然有重复,两张黑桃 A 必须都留着。发牌切片用固定 25 张,改成一副牌或六人局时,把 25 换成len(deck) // 4并处理余数即可,但要注意底牌张数也得跟着变,别只顾着分牌。

3.2 亮主与反主:主花色是谈出来的,不是发出来的

新手写这游戏最容易踩的坑,是发完牌就随机指定一个主花色。真实的《升级》里,主花色是玩家用手上的级牌“叫”出来的,而且可以被反。简化实现可以把优先级压成一条规则:单张级牌能亮,对子级牌能反单张,反主后原亮主失效。

def bid(state, seat_order): """极简亮主:级牌张数多者得;对子(2 张)自动压过单张""" best = (-1, None, None) # (张数, 花色, 座位) for seat in seat_order: cnt = Counter(s for s, r in state["hands"][seat] if r == state["level"]) for suit, n in cnt.items(): if n > best[0]: best = (n, suit, seat) if best[1] is None: # 全场没人亮,随机兜底 best = (0, state["rng"].choice(SUITS), state["dealer"]) state["trump_suit"] = best[1] state["stage"] = "bury" return state

seat_order决定叫主顺序,正序、逆序、从庄家下家开始,规则各地不同,所以留成参数。n > best[0]用的是严格大于,等于时不动,天然实现了“先叫者优先”,不用额外写时间戳。用张数做优先级是一种取巧:单张=1,对子=2,刚好对上“对子反单张”的常见规则。但要注意,真规则里还涉及“无主”“自保”“反无主”这些分支,这份代码没有覆盖,属于可扩展点而不是 bug,扩展时记得在状态里加一个bid_history列表,否则反主撤销就无从谈起。

3.3 扣底与底分:8 张底牌是最大的杠杆

扣底这一步,庄家先把底牌收进手牌变成 33 张,再挑 8 张扣回去。这里有一个顺序问题:收和扣必须成对出现,中间任何校验失败都要能回滚。我的习惯是先在临时列表上操作,最后一次性写回,不要边收边扣。

def bury(state, discard): """庄家收底后扣 8 张;discard 必须是庄家手上真实存在的 8 张""" dealer = state["dealer"] hand = state["hands"][dealer] + state["bottom"] # 33 张 rest = hand[:] for card in discard: if card not in rest: raise ValueError(f"扣底牌不在手上: {card}") rest.remove(card) # 只删一张,保留重复 if len(rest) != 25: raise ValueError("扣底后必须回到 25 张") state["hands"][dealer] = rest state["bottom"] = list(discard) state["stage"] = "play" return state

rest.remove(card)一次只删一张,这是两副牌的关键:手里有两张红桃 K,扣一张下去,另一张必须还在。用列表推导过滤[c for c in rest if c != card]会把两张一起干掉,我见过不止一个人在这栽跟头。扣底完成后底牌的分值会一直悬在那里,等着最后一轮定生死——庄家方赢下最后一轮叫保底,闲家赢叫抠底,抠底要按最后一手牌的张数翻倍:单张 ×2,对子 ×4,甩牌按张数继续翻。这个倍率函数一定要独立出来,别塞在结算逻辑里。

4. 牌型识别与跟牌校验:单张、对子、拖拉机、甩牌

规则层最厚的两块,一个是“这手牌算什么牌型”,一个是“你跟的这手牌合不合法”。前者决定了谁大,后者决定了这局能不能继续。两块都不难,难在边界多,写漏一条就是玩家投诉。

4.1 拖拉机识别:先分组,再判连续

牌型识别我按三步走:先数张数,再分组,最后在组内判连续。张数是 1 就是单张,2 且两张相同就是对子,四张以上且能拆成若干个连续对子就是拖拉机。剩下的统称“甩牌”或“混合”,合法性交给跟牌环节去管。

def classify(cards, level_rank): """返回 (类型, 张数, 对子列表);类型 ∈ single/pair/tractor/mixed/illegal""" if len(cards) == 1: return ("single", 1, []) cnt = Counter(cards) if any(v > 2 for v in cnt.values()): return ("illegal", len(cards), []) # 三张同牌不存在 if len(cards) == 2 and len(cnt) == 1: return ("pair", 2, []) pairs = [c for c, v in cnt.items() if v == 2] if len(pairs) * 2 != len(cards): return ("mixed", len(cards), pairs) # 有单张混在里面 seqs = [seq_of(c, level_rank) for c in pairs] if any(s is None for s in seqs): return ("mixed", len(cards), pairs) # 级牌/王不参与连号 seqs.sort() if all(b - a == 1 for a, b in zip(seqs, seqs[1:])): return ("tractor", len(cards), pairs) return ("mixed", len(cards), pairs)

any(v > 2 ...)这条检查不能省。两副牌里同一张牌最多两张,出现三张只可能是发牌或状态同步出了错,早失败早发现。seq_of返回None时降级为mixed而不是直接判非法,因为“一对级牌 + 一对主花色 A”在很多地方的规则里是合法的组合,只是不算连对。pairs的排序用的是seqs.sort()之后再 zip 比对,靠b - a == 1判严格连续——这里用差值等于 1 而不是“序号递增”,能顺手挡住跨级牌的情况,虽然代价是丢掉了“跳过级牌算连对”这一派规则的支持,需要的话把判据改成“按剔除级牌后的重建序列取下标”即可。

4.2 跟牌校验:先看门,再看张数,最后看对子

跟牌的三条铁律:有这门牌就必须跟这门;张数必须和首出一样;首出是对子,你有对子就必须跟对子。三条按顺序检查,前面的不通过就不用往后走。写成一个纯函数,返回布尔值加原因字符串,方便调试时直接打印。

def check_follow(hand, lead, play, level_rank, trump_suit): """校验 play 是否合法跟牌;hand 是出牌前的手牌""" if len(play) != len(lead): return False, "张数不符" g = group_of(lead[0], level_rank, trump_suit) same_in_play = [c for c in play if group_of(c, level_rank, trump_suit) == g] has_in_hand = any(group_of(c, level_rank, trump_suit) == g for c in hand) if len(same_in_play) != len(play): if has_in_hand: return False, "手里有该门牌,不能垫别的花色" return True, "" # 没这门,随意垫 hand_cnt, play_cnt, lead_cnt = Counter(hand), Counter(play), Counter(lead) for card, need in lead_cnt.items(): if need == 2 and hand_cnt[card] >= 2 and play_cnt[card] < 2: return False, "手里有对子必须跟对" return True, ""

顺序很关键:先判张数,再判门,最后判对子。如果先判对子,你手里有对子但整体张数不对时,报错信息会指向一个假原因,排查时会被带偏。has_in_hand只看“手里有没有这门”,不看具体哪张,这是符合规则的最小判据;但严格版规则还要求“这门牌不够时才允许垫”,实现上要把手里这门牌的张数和首出张数比一下,多出来的部分才允许换成别的门。这份实现做了简化,实战中要补上。另外hand必须传“出牌前”的快照,传成出牌后的手牌会让所有对子校验恒真,这个错误极其隐蔽。

4.3 一轮结算与分牌

四家出完,找最大的一家赢这一轮。判断顺序是:先看有没有主牌,有主牌就在主牌里比;没主牌只在首出那门里比;其余花色一律当垫牌,再大也没用。

POINTS = {"5": 5, "10": 10, "K": 10} def trick_winner(plays, level_rank, trump_suit): """plays 是四手牌的列表,按出牌顺序;返回赢家座位号""" lead_group = group_of(plays[0][0], level_rank, trump_suit) best = 0 for i in range(1, 4): c, cur = plays[i][0], plays[best][0] cg, bg = group_of(c, level_rank, trump_suit), group_of(cur, level_rank, trump_suit) if cg != lead_group and cg != "TRUMP": continue # 垫牌不参与 if cg == "TRUMP" and bg != "TRUMP": best = i elif cg == bg and card_key(c, level_rank, trump_suit) > card_key(cur, level_rank, trump_suit): best = i return best def trick_points(plays): return sum(POINTS.get(r, 0) for cards in plays for _, r in cards)

trick_winner只比每家第一张牌,这是简化处理。甩牌的真实规则是“整手牌的最大牌参与比较,若被压过则甩牌方必须改出最小组合”,这部分我建议单独写一个resolve_throw函数,别硬塞进比较循环里。分数提取用POINTS.get(r, 0),把 5、10、K 之外的点数统一归零,简洁且不会漏。两副牌总分 200 分,抓到 120 分闲家上台,这些阈值写在配置里,别写死在判断条件中。

5. 避坑与排查:规则跑偏时先看这五处

调这套规则最耗时间的不是写代码,是“明明应该这样它偏偏那样”。下面五条是我和几个朋友实际踩过的,按从高频到低频排。

5.1 比大小与牌型的三处翻车

现象:明明打 2,副牌里的红桃 A 却压过了主花色黑桃 4。原因:card_key里把suit == trump_suit的判断放在了rank == level_rank之后,看着没问题,但trump_suit传进来的是None(还没亮主),所有牌都掉进了副牌分支。解决:在card_key入口加一句if trump_suit is None: raise ValueError,把“未亮主不能比大小”变成一个显式错误,而不是让它静默算出一个看似合理的结果。这条我吃过两次亏,第二次才想起来加断言。

现象:一对 4 和一对 5 被判成合法拖拉机,但一对 4 和一对 6 也判成了合法。原因:连续判断写成了max(seqs) - min(seqs) == len(seqs) - 1,这在只有两个元素时恒成立,一对 4 加一对 K 都能通过。解决:老老实实排序后两两比对差值,all(b - a == 1 for a, b in zip(seqs, seqs[1:]))。差值和极差是两种不同的语义,别混用。

现象:出对子时系统提示“手里有对子必须跟对”,但玩家手里确实只有一张那张牌。原因:Counter(hand)统计的是出牌前的手牌,而hand传进来的其实是出牌后已经删掉这张牌的列表,导致hand_cnt[card]恒为 0 或统计不准。解决:在check_follow的调用处强制拷贝一份快照,或者在函数签名上把参数名改成hand_before,让传错这件事在阅读时就能被发现。命名比注释管用。

5.2 状态与交互的两处踩坑

现象:庄家扣底后手牌变成 24 张,下一轮出牌时报索引越界。原因:扣底时用列表推导把牌过滤了一遍,结果因为两副牌里有重复,一次删掉了两张。解决:用list.remove()一次删一张,删完再校验长度必须为 25。更狠的办法是给每张牌带上一个唯一序号(suit, rank, seq_id),从根上消除歧义,代价是打印时得特殊处理。

现象:同一份代码,昨天跑同一局结果一致,今天换个机器结果就变了。原因:洗牌用了全局random.shuffle,没有固定种子,而且手牌分组用了set再遍历,不同 Python 版本的哈希顺序不同。解决:状态里带一个独立的random.Random(seed)实例,所有随机都从这个实例走;所有需要顺序的集合都显式排序,别依赖任何默认顺序。现在每次改规则,我都先用固定种子跑一遍,把结果 diff 出来。

6. 跑通一局:命令行入口、随机策略与固定种子的回归脚本

规则层写完之后,还得有个东西能把它拽起来跑。我一般会加两个入口:一个main给人玩,一个test给机器验。前者薄,只负责渲染和读输入;后者厚,把每一条规则都钉成断言。

6.1 最小入口与一个能自测的随机策略

人肉测试太慢,先塞一个随机策略把整局跑通,能发现大量状态流转问题,比如某一轮没人能出牌、分数统计漏了一手。

# main.py —— 随机 AI 自对弈,跑满 20 局看是否卡死 import argparse def random_policy(hand, lead, level_rank, trump_suit, rng): """随机但合法的出牌:能跟门就跟门,跟不了就垫最小的""" if not lead: return [hand[0]] g = group_of(lead[0], level_rank, trump_suit) same = [c for c in hand if group_of(c, level_rank, trump_suit) == g] pool = same if len(same) >= len(lead) else hand need = len(lead) picks = rng.sample(pool, need) if len(pool) >= need else pool[:] return picks def run(seed, level_rank="2"): state = new_state(level_rank=level_rank, seed=seed) deal(state) bid(state, seat_order=[0, 1, 2, 3]) dealer = state["dealer"] bury(state, state["hands"][dealer][25:]) # 简化:直接扣最后 8 张 lead, leader = [], dealer while all(state["hands"]): plays = [] for i in range(4): seat = (leader + i) % 4 play = random_policy(state["hands"][seat], lead, level_rank, state["trump_suit"], state["rng"]) for c in play: state["hands"][seat].remove(c) plays.append(play) winner = trick_winner(plays, level_rank, state["trump_suit"]) state["points"][0] += trick_points(plays) leader, lead = (leader + winner) % 4, [] return state if __name__ == "__main__": ap = argparse.ArgumentParser() ap.add_argument("--games", type=int, default=20) args = ap.parse_args() for s in range(args.games): st = run(seed=s) assert sum(len(h) for h in st["hands"]) == 0, f"第 {s} 局残留手牌" print("all games finished")

random_policy里的rng.sample有个隐患:pool里可能有重复牌,sample按位置采样不会去重,所以取到两张一样的牌是正常的,别顺手改成random.sample(set(pool), n),那样反而会把对子打散导致非法。主循环用while all(state["hands"])作为终止条件,只要有一家手牌空就停,这比数轮次更稳。assert那行是我加的保险丝:跑完 20 局手牌必须清空,不清空说明某处出牌没有真正从手上移除。

6.2 固定种子回归:把规则写成断言

随机跑通只是及格线,真正省时间的是把规则点钉成用例。下面这张表是我维护的回归清单,每次动规则层就先跑一遍。

用例输入期望盯住的规则
牌堆完整性build_deck()108 张,去重后 54 种两副牌没有漏牌
正主压副主打 2,主黑桃,黑桃 2 vs 红桃 2黑桃 2 更大级牌内部层级
副主压主花色 A打 2,主黑桃,红桃 2 vs 黑桃 A红桃 2 更大主牌区二级分层
跨级连对打 3,一对 4 + 一对 2不算连对级牌剔除后序列
强制跟门手上有黑桃,首出黑桃必须出黑桃跟牌第一铁律
有对跟对手上有对 K,首出对 A必须拆对 K跟牌第三铁律
抠底翻倍最后一轮闲家赢,底牌 20 分,出对子20 × 4 = 80 分底分倍率

跑法很土:pytest或者干脆写成assert脚本,重点是每次改完card_key、classify、check_follow这三个函数都全量跑一遍。我还会额外保存两三个固定种子的终局快照(分数、赢家、剩余手牌),改完对比,只要快照变了就等于宣告规则动了,需要人确认。

这里提醒一句:回归脚本别只测“合法路径”。故意构造几张非法输入——三张同牌、跨花色的对子、跟牌张数不够——看它是不是按预期报错。规则代码最容易烂的地方不是算错,是错得悄无声息。

最后说个习惯。这套代码我最早是在一张纸上画的牌序表,画完才发现打 3 的时候 2 要掉到最小,当时整个排序函数推倒重来。从那以后,我每次动牌序相关的函数之前,都强制先在纸上把“打 2、打 3、打无主”三种情况的主牌顺序各写一遍,确认了再动手。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进