Python标准库实现升级扑克牌:状态机规则引擎与人机AI对战

发布时间:2026/10/25 3:53:15
Python标准库实现升级扑克牌:状态机规则引擎与人机AI对战

简介:一套面向Python初学者与进阶开发者的《升级》扑克牌游戏实现包,围绕纸牌规则、界面交互与AI决策展开,可用于课程设计、项目练习与面向对象编程训练。压缩包共67个文件,以58张jpg牌面与背景素材、5个py源码、2个txt说明、1个pdf文档及1个zip子包组成,整体约2.37MB,兼顾美术资源、程序模块与阅读材料。已有3948人学习,说明该案例在Python游戏开发练习中具有一定参考度。读者可从中获得牌组与玩家类的组织思路、发牌与出牌流程、UI事件绑定、裁判规则判断、异常处理及模块拆分等实践素材,并借助说明文档和预览素材理解项目结构,适合用来对照实现自己的扑克牌小游戏并查漏补缺。

1. 三十行代码打不完的一局牌

周末凑不齐四个人,又想验证「打 2 的时候对家甩牌能不能被主 2 毙掉」这种规则细节,我干脆把《升级》(不少地方叫拖拉机、八十分)写成了一份纯 Python 的终端版扑克牌游戏。四人两副牌共 108 张,亮主、扣底、跟牌、毙牌、末墩算分、升级换庄这一整套流程都在里面跑,人机对战和四家 AI 自动跑满一局两种模式都支持。它适合三类人:刚学规则、想找个不嫌你出牌慢的陪练;想研究牌类游戏状态机怎么拆的中级开发者;以及打算把规则改成自家桌上那套土规矩的二次开发者。整个项目只依赖标准库,拉下来一条python main.py就能开局,不存在卡在pip install上半天的玄学。

2. 项目骨架与运行环境:108 张牌怎么落到目录里

拿到一份牌类项目,我第一件事不是看代码,而是看目录。牌类游戏的复杂度不在算法,在状态——谁先出、谁该跟、这一墩算谁的、下一局谁坐庄。目录结构决定了这些状态被切成了几块,切得乱,后面每加一条规则都要在三个文件里改。

2.1 技术选型:为什么只用标准库

这个项目跑在 Python 3.9 以上,依赖表基本是空的,dataclasses、enum、random、argparse、unittest这几个标准库模块撑起了全部功能。这么选不是偷懒,是血泪经验:牌类项目的乐趣在规则推演,一旦引入图形库或者 Web 框架,新手第一步就卡在环境上,读代码的注意力全被消耗掉了。

命令行界面用argparse做参数入口,牌面渲染用一个render.py把每张牌拼成♠A这样的字符串,颜色靠 ANSI 转义码(\033[31m那一套),Windows 终端不认的话在config.py里关掉就行。这种「零依赖 + 可关的彩色输出」组合,在样板项目里非常实用:想验证规则的人直接跑,想套界面的人把ui/cli.py换掉,core/一行不用动。

常见的三层切法是core(纯规则,不碰输入输出)、ai(只读状态、只返回出牌)、ui(负责问人、打印)。这条边界要守住,否则 AI 里冒出个print,测试就跑不了。

2.2 目录结构:core / ai / ui 三层怎么分

落到磁盘上大概是这样:

upgrade-card/ ├── main.py # 入口:解析参数、组装玩家、启动牌局 ├── config.py # 规则参数:几人几副、底牌数、升级档位 ├── core/ │ ├── card.py # Card 数据类、花色枚举、牌面文本 │ ├── deck.py # 造牌、洗牌、发牌 │ ├── rules.py # 主牌判定、牌力、牌型识别、跟牌校验 │ ├── trick.py # 一墩牌的结算与分数归属 │ ├── score.py # 闲家得分统计、升几级 │ └── game.py # 牌局状态机:发牌 → 亮主 → 扣底 → 出牌 → 结算 ├── ai/ │ ├── base.py # Agent 抽象基类,只暴露 choose() 方法 │ └── greedy.py # 贪心 AI:能管就管、队友大就不管 ├── ui/ │ ├── cli.py # 人类玩家交互:打印手牌、读输入、校验 │ └── render.py # 牌面文本化与配色 └── tests/ └── test_rules.py # 牌型识别与毙牌的单元测试

分层的关键在于数据流向单向:game.py持有全局状态,出牌时依次调用agent.choose(state)拿到候选牌,再交给rules.py校验合法性,最后trick.py结算。AI 拿不到game对象的引用,只能看到一份snapshot字典,这样 AI 就算写崩了也污染不了牌局。

config.py里我会把各地方规则差异全部参数化:DECK_COUNT = 2、SEATS = 4、BOTTOM_SIZE = 8、POINT_CARDS = {5: 5, 10: 10, 13: 10}、LEVEL_UP_RULES = [(0, 3), (40, 2), (80, 1), (120, 0)]。最后这个列表的意思是「闲家拿到 0~39 分,庄家升 3 级」,改规则只动这里,逻辑代码不需要理解具体数字。

2.3 跑起来:从解压到第一局的三条命令

环境干净的情况下,三条命令足够:

python -m venv .venv && source .venv/bin/activate # Windows 用 .venv\Scripts\activate python -m pip install -r requirements.txt # 目前是空文件,占位用 python main.py --seed 20240601 --human-seat 0

--seed是我建议每次都带的参数。牌类项目最大的麻烦是「上一局那个 bug 复现不出来」,固定种子之后整副牌的顺序完全确定,报 bug 时把种子和出牌序列一贴,别人就能原样重放。--human-seat决定你坐哪一桌位,0 和 2 是对家、也就是一队,想快速验证配合逻辑就切到 1 号位试试。

再补两个常用开关。--autoplay让四家全部交给 AI 跑完整局,用来做批量冒烟测试,跑一千局看看会不会抛异常;--level 2指定起始打的级别,方便直接跳到「打 A」验收升级判定。入口文件本身很短:

# main.py import argparse from core.game import Game from ai.greedy import GreedyAgent from ui.cli import CliPlayer def build_players(seats: int, human_seat: int): """按座位号组装玩家,只有 human_seat 是真人,其余走贪心 AI""" players = [] for i in range(seats): players.append(CliPlayer(i) if i == human_seat else GreedyAgent(i)) return players def main(): parser = argparse.ArgumentParser(description="升级(拖拉机)四人两副牌") parser.add_argument("--seed", type=int, default=None, help="随机种子,固定后牌局可复现") parser.add_argument("--human-seat", type=int, default=0, choices=[0, 1, 2, 3]) parser.add_argument("--level", default="2", help="起始级牌,2 到 A") parser.add_argument("--autoplay", action="store_true", help="四家全 AI,用于冒烟测试") args = parser.parse_args() human = -1 if args.autoplay else args.human_seat game = Game(players=build_players(4, human), start_level=args.level, seed=args.seed) game.run() if __name__ == "__main__": main()

这段代码没什么技巧,重点在human = -1 if args.autoplay else args.human_seat这一行:autoplay模式下把真人座位号置成 -1,build_players自然全部走 AI,不用在Game里再塞一个分支判断。参数说明也直接落在help里,python main.py -h就是一份自解释文档。

提示:第一次跑建议先用--autoplay --seed 1确认四家 AI 能完整打完一局,再切回人机模式。牌局中途卡死时,问题往往出在状态机而不是你的输入。

3. 核心数据结构:一张牌在内存里长什么样

牌类项目的第一个设计决策是「怎么表示一张牌」,这个决定会一直影响到排序、判对、比大小。见过用字符串"♠A"当牌的项目,写起来直观,等到要给手牌排序时就得写一堆if rank == "A"的分支,非常难受。

3.1 Card 与牌力编码:把「主」这件事算出来

我用dataclass定义一个不可变的Card,字段只有三个:花色、点数、来自第几副牌。第三个别小看,两副牌里有两张黑桃 A,判对子时它们必须能配对,判「是不是同一张」时又得区分开。

# core/card.py from dataclasses import dataclass from enum import IntEnum class Suit(IntEnum): CLUB = 0 # 梅花 DIAMOND = 1 # 方块 HEART = 2 # 红桃 SPADE = 3 # 黑桃 JOKER = 4 # 大小王,花色位单独占一格 RANK_TEXT = {11: "J", 12: "Q", 13: "K", 14: "A", 15: "小王", 16: "大王"} SUIT_TEXT = {Suit.CLUB: "♣", Suit.DIAMOND: "♦", Suit.HEART: "♥", Suit.SPADE: "♠"} @dataclass(frozen=True) class Card: suit: Suit rank: int # 2..14 表示 2..A,15 小王,16 大王 deck: int = 0 # 0 或 1,标记来自第几副牌 def __str__(self) -> str: if self.rank >= 15: return RANK_TEXT[self.rank] return f"{SUIT_TEXT[self.suit]}{RANK_TEXT.get(self.rank, str(self.rank))}" @property def ident(self) -> tuple: """物理唯一标识,用于判重、从手牌里删除""" return (self.suit, self.rank, self.deck) @property def face(self) -> tuple: """逻辑牌面,两张黑桃 A 的 face 相同,判对子用这个""" return (self.suit, self.rank)

frozen=True让Card可哈希,能直接丢进set去重;ident和face两个属性把「物理唯一」和「逻辑相同」拆开,后面判对子只比较face,判重复删除只比较ident。这个拆分看着啰嗦,但省掉了大量deck字段到处传的麻烦。

真正决定牌大小的是rules.py里的两个函数。升级的牌力排序是分段函数,主牌区内部还有层级:

# core/rules.py JOKER_SMALL, JOKER_BIG = 15, 16 def is_trump(card, trump_suit, level_rank) -> bool: """判断一张牌是否属于主牌区""" if card.rank >= JOKER_SMALL: # 大小王恒为主 return True if card.rank == level_rank: # 级牌恒为主(含副级牌) return True return trump_suit is not None and card.suit == trump_suit def power(card, trump_suit, level_rank) -> int: """牌力数值,越大越强;同值表示互不能压""" if card.rank == JOKER_BIG: return 1000 if card.rank == JOKER_SMALL: return 900 if card.rank == level_rank: # 主级牌最大;三门副级牌彼此等值,靠先后手决定谁大 return 800 if card.suit == trump_suit else 700 if card.suit == trump_suit: return 200 + card.rank # 主花色普通牌,排在副牌之上 return card.rank # 副牌按点数比

参数说明:trump_suit是本局主花色,level_rank是当前打的级别(2 到 14)。power返回的是整数,比较时用严格大于,这一条很关键——副级牌之间的power都是 700,用>=判大小会让后出的副 2 压掉先出的副 2,规则上是不允许的。主花色普通牌统一加 200 偏移量,是为了让「主牌永远压副牌」这件事在一行比较里自然成立,不用再写一层is_trump分支。

3.2 发牌与排序:两副牌 108 张怎么切

造牌就是两层循环:两副牌各 52 张加两张王,总共 108 张。发牌时每人 25 张,剩下 8 张进底牌,25×4+8 刚好 108,这个整除关系在改配置时一定要核对。

# core/deck.py import random from .card import Card, Suit NORMAL_RANKS = list(range(2, 15)) # 2..A def build_deck(deck_count: int = 2) -> list: cards = [] for d in range(deck_count): for suit in (Suit.CLUB, Suit.DIAMOND, Suit.HEART, Suit.SPADE): for rank in NORMAL_RANKS: cards.append(Card(suit, rank, d)) cards.append(Card(Suit.JOKER, 15, d)) # 小王 cards.append(Card(Suit.JOKER, 16, d)) # 大王 return cards def deal(cards, seats: int = 4, bottom: int = 8, seed=None): rng = random.Random(seed) rng.shuffle(cards) per = (len(cards) - bottom) // seats # 108-8=100,每人 25 张 hands = [cards[i * per:(i + 1) * per] for i in range(seats)] return hands, cards[seats * per:]

seed传进random.Random(seed)而不是用全局random.seed(),这样多个牌局并行跑测试时互不干扰,是个小但值得养成的习惯。per用整除算出来而不是写死 25,改三副牌或者改成 6 人局时这里自动适配。

发完牌就得排序,否则手牌打出来是乱的。排序键不能只用power,还得先按「主牌区/副牌花色」分块,否则主牌和副牌会穿插在一起:

def sort_hand(hand, trump_suit, level_rank): """主牌排最左,其余花色按固定顺序;同区内按牌力降序""" def key(c): trump = is_trump(c, trump_suit, level_rank) group = 0 if trump else int(c.suit) + 1 return (group, -power(c, trump_suit, level_rank), c.deck) return sorted(hand, key=key)

这里-power取负是为了降序排列,c.deck兜底保证两张完全相同的牌排序结果稳定,不会每次刷新屏幕都跳位置。

3.3 亮主与底牌:庄家是怎么定下来的

亮主是升级里规则差异最大的环节。本项目走常见做法:发牌后从庄家下家开始轮流表态,谁手里有当前级牌就可以亮出对应花色为主;后续玩家如果手上有同一级的对子(比如两张级牌),可以反主,把花色换掉。没有人亮主则默认无主,只有级牌和王算主。

game.py用阶段枚举驱动整个流程,比一串if清晰得多:

# core/game.py(节选) from enum import Enum, auto class Phase(Enum): BID = auto() # 亮主 BOTTOM = auto() # 庄家扣底 PLAY = auto() # 出牌 SCORE = auto() # 结算 OVER = auto() # 局终 def settle_bid(players, level_rank): """返回 (trump_suit, banker_seat);无人亮主时 trump_suit 为 None""" trump, banker = None, None for p in players: # 按座位顺序询问 choice = p.declare(level_rank) # 返回 None 或 (suit, 是否反主) if choice is None: continue suit, is_counter = choice if trump is None and not is_counter: trump, banker = suit, p.seat elif is_counter and trump is not None: trump = suit # 反主成功,庄家不变 return trump, banker

declare()的具体判断放在玩家对象里,AI 和真人各实现一份:AI 的策略简单粗暴——手里该花色的级牌或王多就亮,真人则由CliPlayer弹提示。返回值第二项is_counter表示反主,这里刻意不做「反主次数上限」的限制,因为各地规矩从「不许反」到「无限反」都有,需要的话在config.py加个MAX_COUNTER就行。无人亮主时trump保持None,后面所有is_trump调用都要能正确处理这个空值,这也正是rules.py里那句trump_suit is not None的来历。

扣底阶段庄家从 25 张手牌选出 8 张埋进底牌,底牌不参与出牌,但里面的分会计入最后一墩的翻倍计算,所以怎么扣是个策略问题。AI 的扣底逻辑在ai/greedy.py里:优先扣掉没有分、不成对、不参与主牌的小副牌,同时避免把某个花色的牌扣到一张不剩——留一张有时候能避免被逼着垫牌。

4. 牌型识别与一墩结算:甩牌、毙牌、扣底的判定链

规则引擎是这类项目最容易翻车的地方。牌型、跟牌、比大小是三件事,混在一个函数里写,两百行之后连作者自己都不敢改。

4.1 牌型识别:单张、对子、拖拉机、甩牌

先定义牌型枚举,再写识别函数。升级里最麻烦的是「拖拉机」——同花色的连续对子,长度可以是 2 对、3 对甚至更多,而级牌会把副牌的连续序列打断,比如打 2 的时候,副牌 A 和 3 之间是断的。

from enum import Enum class Pattern(Enum): SINGLE = "single" PAIR = "pair" TRACTOR = "tractor" THROW = "throw" # 甩牌:多组不同牌型的组合 INVALID = "invalid" def group_key(card, ctx): """同组才能互相比较:主牌归为一组,副牌按花色分组""" return "T" if is_trump(card, ctx.trump_suit, ctx.level_rank) else card.suit def seq_index(card, ctx): """把点数映射成连续序号,跳过被级牌占位的坑""" r = card.rank if r >= JOKER_SMALL: return {15: 14, 16: 15}[r] if card.suit == ctx.trump_suit: # 主花色内部:副级牌和主级牌位置固定,其余按点数排 return r - 1 if r < ctx.level_rank else r return r - 1 if r < ctx.level_rank else r - 1 # 副牌序列跳过级牌

seq_index是整段逻辑的核心。假设打 2,副牌方块的 3、4、5 应该算连续,但原始点数是 3、4、5,本来就是连续的;而 A(14) 和 3 之间因为 2 被抽走,需要让它们「相邻」,所以要按「小于级牌减一」的规则压一位。这个函数的返回值只用于判连续,不用于比大小,两套编号分开能避免互相干扰。

拿到序号之后,拖拉机判定就水到渠成:

def classify(cards, ctx): """返回 (Pattern, 比较用的 key);key 越大越强""" n = len(cards) if n == 0: return Pattern.INVALID, None groups = {} for c in cards: groups.setdefault(group_key(c, ctx), []).append(c) if len(groups) > 1: return Pattern.THROW, None # 跨组的组合一律视为甩牌 cards = sorted(cards, key=lambda c: seq_index(c, ctx)) if n == 1: return Pattern.SINGLE, power(cards[0], ctx.trump_suit, ctx.level_rank) if n == 2: if cards[0].face == cards[1].face: # 注意用 face,两张黑桃 A 算一对 return Pattern.PAIR, power(cards[0], ctx.trump_suit, ctx.level_rank) return Pattern.INVALID, None if n % 2 != 0: return Pattern.INVALID, None pairs = [(cards[i], cards[i + 1]) for i in range(0, n, 2)] for a, b in pairs: if a.face != b.face: return Pattern.INVALID, None idx = [seq_index(a, ctx) for a, _ in pairs] if all(idx[i + 1] - idx[i] == 1 for i in range(len(idx) - 1)): top = pairs[-1][0] return Pattern.TRACTOR, (len(pairs), power(top, ctx.trump_suit, ctx.level_rank)) return Pattern.INVALID, None

参数说明:ctx是一个轻量上下文对象,带上trump_suit和level_rank,全篇传递它比传两个散参好维护。返回值里 PAIR 和 TRACTOR 的 key 结构不同,PAIR 是单个整数,TRACTOR 是(对数, 最大对牌力)元组,比较时要先比对数再比牌力——长度不同的拖拉机根本不允许互压,这一条在compare里显式判断。

甩牌单独处理:首家用多组牌同时出,只要别人压不住就全部生效;一旦被压,只能出其中最大的那一组。兜底逻辑写在trick.py里,识别阶段只负责认出「这是甩牌」。

4.2 跟牌合法性:为什么「有对不跟对」要单独校验

跟牌校验是玩家最容易钻空子的地方,也是最容易写出漏洞的地方。规则可以概括为三条:手里有该花色就必须跟该花色(除非手里不够);该花色里如果有对子,出对子时必须跟对;如果首家出的不是单张,跟牌张数必须一致。

def validate_follow(hand, lead, play, ctx): """返回 (是否合法, 原因);lead 是首家牌,play 是跟出的牌""" need = len(lead) if len(play) != need: return False, f"张数不对,应出 {need} 张" lead_group = group_key(lead[0], ctx) same = [c for c in hand if group_key(c, ctx) == lead_group] if len(same) >= need: # 手里该花色够,必须全出该花色 if any(group_key(c, ctx) != lead_group for c in play): return False, "手里该花色张数够,不能垫别的花色" # 首家出对子时,手里有对子必须跟对 lead_pairs = pair_keys(lead, ctx) if lead_pairs: play_pairs = pair_keys(play, ctx) for pk in lead_pairs: if pk in pair_keys(same, ctx) and pk not in play_pairs: return False, "手里有对子,必须跟对" return True, "" # 该花色不够,先出光手里有的,剩下随意垫 if not all(c in same for c in play[:len(same)]): return False, "应先把该花色出完" return True, ""

pair_keys返回一个set,元素是face元组。这里用集合并集交集判断,比逐个遍历写嵌套循环清楚得多。注意c in same用的是Card的相等语义,dataclass默认按字段比较,两张牌只要花色点数副号全同就是相等,判牌「在不在手里」正好合适。

返回值带上原因字符串是个实用习惯:真人交互时直接把原因打给玩家看,AI 调试时也能立刻知道是哪条规则没过,比返回一个False然后到处加断点强太多。

4.3 一墩结算与分数归属

一墩牌打完,要决定谁赢、分归谁。判定顺序是固定的:先看有没有主牌毙,再在所有同组牌里比牌型比牌力,赢的人拿走这一墩里所有分。

# core/trick.py from .rules import classify, group_key, Pattern POINT_VALUE = {5: 5, 10: 10, 13: 10} def trick_points(cards): """一墩牌里的分数总和""" return sum(POINT_VALUE.get(c.rank, 0) for c in cards) def resolve(trick, ctx): """trick 是 [(seat, cards), ...],index 0 为首家;返回 (赢家 seat, 分数)""" lead_seat, lead_cards = trick[0] lead_group = group_key(lead_cards[0], ctx) lead_pattern, lead_key = classify(lead_cards, ctx) winner = lead_seat best = (lead_pattern, lead_key, False) # 第三位:是否已由主牌毙掉 for seat, cards in trick[1:]: pat, key = classify(cards, ctx) if pat == Pattern.INVALID: continue # 甩牌失败等情况,不参与竞争 is_trump_play = group_key(cards[0], ctx) == "T" is_lead_trump = lead_group == "T" if is_trump_play and not is_lead_trump: # 副牌局被主牌毙,只有更大的毙牌才能反毙 if not best[2] or key > best[1]: best = (pat, key, True) winner = seat continue if best[2]: continue # 已经被毙了,副牌再大也没用 if pat != lead_pattern or len(cards) != len(lead_cards): continue # 牌型或长度不同,压不住 if key > best[1]: best = (pat, key, best[2]) winner = seat return winner, trick_points([c for _, cs in trick for c in cs])

参数说明:trick保持出牌顺序,best三元组里第三位是个状态位,标记「当前最大的是不是主牌毙的」。这个状态位不能省:一旦被毙,后面的副牌无论多大都直接跳过,否则会出现副牌反压主牌的离谱结果。

分数归属在score.py里累加:闲家赢的墩计分,庄家赢的墩不计。牌局结束后按LEVEL_UP_RULES查表决定升几级。末墩的扣底翻倍单独处理——如果最后一墩是闲家赢的,底牌里的分要按「单张 ×2、对子 ×4」之类的倍数计入,具体倍数同样放进config.py,别写死在结算函数里。

5. 常见问题排查:跑通之后最先撞上的五个坑

规则类项目有个特点:能跑起来和跑得对是两件完全不同的事。下面这几条是我在调试和自测里反复撞上的。

5.1 环境与启动相关

现象一:Windows 下终端满屏乱码,牌面显示成方块问号。原因:render.py里用了 ANSI 转义序列和♠♥♦♣这些 Unicode 字符,旧版控制台默认编码不是 UTF-8。 解决:先执行chcp 65001切到 UTF-8,或者在config.py里把USE_COLOR关掉、USE_ASCII打开,花色退化成 C/D/H/S 四个字母。有条件的话换 Windows Terminal,这个问题基本消失。

现象二:python main.py --seed 1每次发到的牌都不一样。原因:传入的seed类型是字符串,random.Random("1")和random.Random(1)结果不同,另外代码里如果还残留全局random.shuffle()调用,就算传了种子也白搭。 解决:argparse里把type=int写上,并且全项目只用random.Random(seed)这个实例,禁止调用模块级random.xxx。改完拿两个终端各跑一次,手牌打印必须逐字节相同。

5.2 规则判定相关

现象三:明明出了更大的对子,却被判输。原因:比较时把power的返回值直接比大小,没先比牌型。一对副 3 出去,对方跟了一对主 2,牌型对得上、power也更大,但如果手里那对主级牌的花色不同——一张红桃 2 一张黑桃 2,它们在很多地方规则里根本不算一对。 解决:判对子统一走face相等,也就是花色和点数都要一致。is_pair里加一句assert a.suit == b.suit or a.rank >= 15,让王的对子单独放行。写完之后拿tests/test_rules.py里那组「副级牌不同花色」的用例回归一遍。

现象四:跟牌时明明手里有一对,系统却允许单出一张。原因:validate_follow里判断「有对必跟对」用的是play里有没有对子,而不是「手里该花色里有没有对子」。顺序反了,等于没校验。 解决:先算pair_keys(same, ctx)拿到手里该花色的对子集合,再逐个检查这些对子是否都被play覆盖。这段建议单独写成一个纯函数并配三个用例:有对跟对(通过)、有对不跟对(拦截)、无对可跟(放行)。

5.3 牌局状态相关

现象五:跑--autoplay跑到中途卡住不动,日志停在某一墩。原因:AI 返回了手里没有的牌,或者返回空列表,validate_follow一直判非法,而主循环没有重试上限,就死循环了。 解决:在game.py的请求出牌处加一圈循环上限,比如最多重试 20 次,超限就打印当前手牌、lead牌和 AI 的返回值,直接抛异常退出。这类「黑匣子」现象,宁可当场炸掉也别静默转圈,牌类项目不缺口子,翻车一次修很久。

注意:排查时优先用--seed固定牌局,把「哪一手出错了」定位到具体座位和具体牌,比漫无目的地读代码快一个数量级。

6. 二次开发:把规则改成自家玩法要动哪几处

规则改造的入口就三个文件:config.py放参数,rules.py放判定,game.py放流程。先把这句话记住,后面所有改动都能对号入座。

改级数是最简单的一种。有人打「3 副牌 6 人」,只需要DECK_COUNT = 3、SEATS = 6,但要注意per = (len(cards) - bottom) // seats算出来不能有余数,3 副牌 162 张、底牌 8 张、6 人局就是 25.6 张,除不尽。这时得改底牌数或者座位数,让(162 - bottom) % 6 == 0,比如底牌改成 6 张,每人 26 张。这个约束建议直接在Game.__init__里加断言拦住。

改升级档位更常见。不同地方对「闲家拿多少分升不升级」的说法差别很大,全部收进LEVEL_UP_RULES列表,用二分或者线性查表都行:

def level_up_gain(banker_points_taken: int, rules) -> int: """返回庄家升几级;负数表示下台,庄家换人""" for threshold, gain in rules: if banker_points_taken < threshold: return gain return 0

rules必须按分数从小到大排,函数按顺序返回第一个满足的档位。加一档「闲家拿满 200 分反升 3 级」就在列表末尾补(200, -3),语义靠正负号区分,不用再改函数体。

想加难度分层,就在ai/下再开一层。base.py里只要求实现choose(snapshot, legal_moves),签名固定,上层Game完全不感知具体策略。我的做法是先跑一个纯随机 AI 当基线,再叠一个贪心 AI,用--autoplay各跑一千局看平均得分,超过基线 15% 左右才算这版 AI 真有用。验证强烈建议用单元测试而不是人肉打牌:

改造点验证方式关注指标
升级档位构造固定分数牌局直接调level_up_gain边界值 0/39/40/79/80/119
牌型识别tests/test_rules.py参数化用例副级牌、跨级牌连对、甩牌降级
新 AI 策略--autoplay --seed 1..1000批跑平均分、非法出牌次数必须为 0
UI 替换只动ui/,跑同一 seed 对比决策日志前后出牌序列应完全一致

最后一条是我吃过亏才加上的:换 UI、换 AI、甚至只是重构sort_hand,都拿同一个种子跑一遍,把每一手的出牌序列和改动前 diff 一次。牌类游戏的状态空间太大,人肉点几十手根本测不出问题,只有让机器把同一局重放两遍、逐行对比,才敢说改动是安全的。从那以后我每次动core/rules.py都强制走一遍这个重放对比,哪怕只改了一行比较符号。希望帮到你。

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