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

发布时间:2026/10/25 3:52:15
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 deckdecks这个参数不是摆设。想跑三人一副牌的简化版把它改成 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 suitseq_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_rank2, dealer0, seedNone): return { level: level_rank, # 当前打几 trump_suit: None, # None 表示还没亮主 dealer: dealer, # 庄家座位 0~30/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 stateseed是我在这份代码里最看重的一个参数。不传就是真随机传了就是可复现调规则的时候没有它根本没法定位问题——同一个 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 stateseat_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 staterest.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_rank2): state new_state(level_ranklevel_rank, seedseed) 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, typeint, default20) args ap.parse_args() for s in range(args.games): st run(seeds) 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、打无主”三种情况的主牌顺序各写一遍确认了再动手。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询