Python实现金拉米纸牌游戏:从状态机建模到AI防守策略的完整算法实践 简介Gin-Rummy-Card-Game是一份基于Java实现的杜松子酒接龙纸牌游戏源码面向Java入门者、课程设计学生以及棋牌类游戏爱好者。项目完整覆盖了经典规则中的发牌、组牌、跑动和设置组合判定并设计了可自动决策的机器人对手便于学习回合制对战程序与基础AI策略。压缩包共163个文件体积约1.44MB主要包含15个Java源文件、18个class编译文件、108个gif演示图片、以及XML配置和jar依赖库等。其中Card、Deck、Hand、CardSet等类清晰展示了面向对象建模思路gif文件可直观观察牌局界面与出牌交互过程。资源已有214人学习适合用来研究纸牌游戏的数据结构设计、手牌匹配算法、机器人决策逻辑以及Java图形界面开发。通过阅读源码和运行测试读者能掌握完整的小型游戏项目从编码到调试的流程并可作为后续扩展双人联机或AI难度的参考基础。1. Gin-Rummy-Card-Game 在做什么一张被低估的算法练手题Gin-Rummy-Card-Game 是把双人纸牌游戏金拉米从现实规则搬进代码的一次完整练习。它不追求画面核心是把 52 张牌的编码、回合状态机、牌型检测、死牌计分和对手 AI 装进一个能跑通、能测试、能复现的小项目里。对想做课程设计又不想烂大街的人它比贪吃蛇有深度比象棋引擎好收尾。我接这类需求时最常被问的问题是“规则这么简单代码能写多长”。答案是规则段落确实短但把拆牌、算死牌、出牌防守三个子问题写清楚通常要一千行往上。这篇内容从数据建模讲起到可跑的回合循环、AI 和回归测试收尾代码都直接可复现适合想一次做完、不留半成品的开发者。2. 先把规则变成数据牌、手牌与牌型的建模方式2.1 用 0–51 的整数编码一张牌调试友好和判等简单初学者最容易犯的第一个错是用字符串表示牌比如♠A或A♠。字符串确实好读但接下来所有逻辑都要跟字符串解析打交道比大小要先切字符、判花色要先看后缀、排序要写自定义 key。更麻烦的是7S和7♥这种肉眼能区分、程序却要额外判断的字符串出错率极高。我一般直接用 0–51 的整数编码规则只有两条cid // 13是花色cid % 13是点数。这样的好处是判等就是整数判等排序就是整数排序调试时再用一个纯函数转回可读牌面即可。RANKS A23456789TJQK SUITS SHDC # S黑桃, H红心, D方块, C梅花 def card_str(cid: int) - str: 把 0-51 的牌编码转成可读牌面日志和调试时用。 return RANKS[cid % 13] SUITS[cid // 13] def deadwood_value(cid: int) - int: 死牌分A12-9 照面值10/J/Q/K 都是 10 分。 rank cid % 13 if rank 0: return 1 if rank 10: return 10 return rank 1参数说明RANKS里把 10 记作T是为了保证所有牌面都是两个字符card_str(TS)就是 10 黑桃日志对齐好看。deadwood_value是后面所有 AI 决策的地基计算时只依赖cid % 13跟花色无关所以写成一个独立函数而不是散落在各处。这里有一个容易被忽略的点按cid排序是“花色优先、点数其次”但判断顺子时需要“点数连续、花色相同”所以排序 key 要写成( cid // 13, cid % 13 )而不是直接sorted(hand)。我在这个细节上吃过亏后面避坑章会再展开。2.2 手牌、牌堆与弃牌堆回合状态机的最小字段金拉米的完整状态其实只有四样东西两个玩家的手牌、未翻开的牌堆、弃牌堆、当前轮到谁。把这四样装进一个 dataclass整个游戏就能任意保存、加载、回放。这也是“可测试”的前提因为每个测试用例都可以直接构造一个GameState实例。from dataclasses import dataclass, field dataclass class GameState: hands: list[list[int]] # 两个玩家的手牌hands[0] 是当前回合玩家 stock: list[int] # 牌堆从列表尾部摸牌 discard: list[int] # 弃牌堆顶牌在列表尾部 turn: int # 0 或 1轮到他行动 knocked: bool False # 本回合是否已有人敲桌 scores: list[int] field(default_factorylambda: [0, 0])选stock和discard都用“列表尾部当顶”而不是头部是因为pop()和append()都是 O(1)摸牌、弃牌时不用pop(0)那种 O(n) 操作。knocked这个布尔值很多人会漏掉它标记本回合是否已经结束否则状态恢复时无法区分“还没人敲”和“已经敲完在计分”。实战里建议把GameState当成纯数据类所有改状态的逻辑都放到GinRummyGame的方法里而不是让 AI 直接改hands。这样后续写 AI 的时候AI 只负责“决策”不负责“执行”逻辑边界清楚出 bug 也好定位。2.3 拆牌与算死牌点数分组和同花顺子两条路金拉米的牌型只有两种同点数的一组三张或四张同花色连续的三张以上。拆牌算法就是在这两种牌型里做组合搜索。先写两个检测函数一个处理组、一个处理顺。def find_sets(hand): 找三张或四张同点数不考虑花色。 by_rank {} for c in hand: by_rank.setdefault(c % 13, []).append(c) return [tuple(cards) for cards in by_rank.values() if len(cards) 3] def find_runs(hand): 找同花色、点数连续的三张及以上。 runs [] for suit in range(4): ranks sorted({c % 13 for c in hand if c // 13 suit}) seq [] for r in ranks: if seq and r seq[-1] 1: seq.append(r) else: if len(seq) 3: runs.append(tuple(suit * 13 r for r in seq)) seq [r] if len(seq) 3: runs.append(tuple(suit * 13 r for r in seq)) return runsfind_sets的实现逻辑按点数分组后只要某组数量大于等于 3 就是一个合法牌型。find_runs则先按花色过滤再对点数排序后做一次线性扫描找出所有长度大于等于 3 的连续段。注意ranks用set去重虽然标准牌组里同花色不会有重复点数但防御性去重可以避免后续调试时喂进脏数据。这两个函数只负责“找出所有可能的牌型”不负责“如何组合”。因为同一张牌可能同时属于多个牌型比如7S既能在5S 6S 7S这个顺子里也能在7S 7H 7D这个组里。真正算死牌时要在这堆互相重叠的牌型里选一个“互不冲突且死牌分最小”的组合这是下一章计分部分的核心也是后面 AI 拆牌的同一个函数。3. 用 Python 把 Gin Rummy 的一回合跑通发牌、摸牌、出牌与敲桌判定3.1 最小可跑的初始化与摸牌逻辑有了状态类游戏主循环就顺理成章。我先写一个GinRummyGame类把发牌、洗牌、摸牌封装进去。这里最关键的一点是所有随机性都走self.rng而不是直接调random.shuffle模块级函数。只有这样给了seed之后每一局都能精确复现后面调试 AI 死循环时才能回放现场。import random class GinRummyGame: def __init__(self, seedNone): self.rng random.Random(seed) self.deck list(range(52)) self.rng.shuffle(self.deck) self.hands [self.deck[:10], self.deck[10:20]] self.stock self.deck[20:] self.discard [self.stock.pop()] # 翻开的第一张做弃牌堆底 self.scores [0, 0] self.turn 0 def draw(self, from_discardFalse): 摸牌from_discardTrue 时从弃牌堆顶摸否则从牌堆摸。 if from_discard: card self.discard.pop() else: card self.stock.pop() self.hands[self.turn].append(card) return card发牌逻辑中self.deck[:10]给玩家 0self.deck[10:20]给玩家 1剩下的全是牌堆再把牌堆顶的第一张翻到弃牌堆。draw方法返回摸到的牌调用方可以拿它做决策。参数from_discard必须显式传因为不同的摸牌来源对后续出牌约束完全不同这在 3.2 讲。这里的分层设计是draw只负责改状态不负责判断“能不能摸”。合法性校验由更上层的回合方法负责比如摸牌前检查stock是否为空。把校验和状态变更拆开是避免出现“非法操作改了数据”这类隐蔽 bug 的最直接手段。3.2 合法动作校验出牌、敲桌和弃牌堆的三个约束金拉米一回合的标准动作序列是摸一张牌能敲桌就选择敲或继续然后必须弃一张牌。这里有三条规则很容易漏漏任何一条都会让 AI 打出的逻辑在规则上不合法。def discard_card(self, card): 从手牌中移除一张并放到弃牌堆顶随后切换到对家回合。 self.hands[self.turn].remove(card) self.discard.append(card) self.turn 1 - self.turn def can_knock(self, hand) - bool: 手牌死牌分小于等于 10 就可以敲桌。 _, dead best_knock(list(hand)) return dead 10第一条约束只能弃自己手里的牌list.remove(card)在牌不在手牌时会抛ValueError这其实是个好事让非法操作尽早暴露。第二条约束弃牌堆顶那张刚被摸走的牌本回合不能再弃回去。很多变体规则里这是禁手实现时需要在GameState里加一个last_drawn_from_discard字段discard_card里判断“如果弃的这张等于刚摸的那张就拒绝”。第三条约束敲桌发生在弃牌之前敲桌后本回合不再弃牌。所以回合循环的顺序必须是“摸牌 → 检查可敲 → 弃牌”而不是“摸牌 → 弃牌 → 检查可敲”。我见过把顺序写反导致死牌分等于 0 却还弃一张牌出去的翻车案例后面避坑章会细说。can_knock调用的best_knock是全局拆牌函数它在 3.3 里给出完整实现。3.3 一局结束时的计分死牌差、Gin 与反超敲桌之后两家各自把手牌拆成牌型加死牌按死牌分差算分。规则表看起来简单实现时的分支顺序却是个大坑尤其是“Gin死牌为 0”和“反超对方死牌更少或平手”两个分支不能搞反。局面判定条件得分普通敲桌敲桌者死牌分 对家死牌分敲桌者加两方死牌分差值Gin敲桌者死牌分 0敲桌者加 25 再加对家全部死牌分反超对家死牌分 ≤ 敲桌者死牌分敲桌者非 Gin对家加 25 再加差值计分前需要先算出“最小死牌组合”这就用到了 2.3 里的find_sets和find_runs。十张手牌下牌型数量有限用二进制掩码枚举所有互不重叠的牌型组合完全可行。def best_knock(hand): 在所有互不重叠的牌型组合里找死牌分最小的那个。 hand set(hand) melds find_sets(hand) find_runs(hand) best_deadwood sum(deadwood_value(c) for c in hand) best_choice [] for mask in range(1 len(melds)): used, chosen, ok set(), [], True for i, m in enumerate(melds): if (mask i) 1: if used set(m): ok False break used | set(m) chosen.append(m) if ok: dead sum(deadwood_value(c) for c in hand if c not in used) if dead best_deadwood: best_deadwood dead best_choice chosen return best_choice, best_deadwood逻辑说明枚举的是“选哪几个牌型”的二进制掩码mask的每一位对应melds里一个牌型used set(m)检查当前牌型和已选牌型是否有共用牌有就剪掉这个分支。hand转成set是为了让c not in used的运算变成 O(1)。10 张牌最多产生十几个候选牌型2^len也就几万次迭代跑一局完全无感知。有了best_knock计分方法顺理成章def finish_round(self, knocker): 敲桌者敲桌后结算本局分数返回当前总分。 other 1 - knocker _, dead_knocker best_knock(self.hands[knocker]) _, dead_other best_knock(self.hands[other]) if dead_knocker 0: self.scores[knocker] 25 dead_other elif dead_other dead_knocker: self.scores[other] 25 (dead_knocker - dead_other) else: self.scores[knocker] dead_other - dead_knocker return self.scores计分顺序上dead_knocker 0必须最先判断。如果先判断dead_other dead_knocker那 Gin 且对家也 0 死牌的极端情况会被错误地算成对手反超白白丢掉 25 分。反超规则存在的意义是惩罚鲁莽敲桌你死牌 10、我死牌 5你敲了桌反而送我 25 分加差值这局就翻盘了。4. 给单人模式一个会玩牌的对手AI 拆牌与防守出牌4.1 最小死牌拆法10 张手牌上的组合搜索单人模式能不能留住玩家九成取决于 AI 会不会拆牌。3.3 的best_knock本质上就是一个完美的拆牌器给任意 10 张牌它能算出理论最小死牌分。AI 的摸牌、弃牌、敲桌决策全部可以建立在它之上所以这章的 AI 其实就是把同一个函数用在三个不同的决策点上。为什么不用贪心很多人第一版会写“找到一个牌型就收下剩下的算死牌”。这个做法在牌型不重叠时确实对但一旦出现共用牌就翻车。比如手牌同时有5S 6S 7S和7S 7H 7D贪心先收了顺子7S被困在顺子里剩下的7H 7D组不成三张白白多两张死牌。而best_knock的枚举会把两种拆分都试一遍选出死牌更小的那个。时间复杂度上10 张牌的候选牌型一般不超过 15 个2^15也就是三万多次迭代单次调用耗时可以忽略。这个“暴力枚举”之所以可行正是因为金拉米手牌规模恰好卡在 10 这个数字上如果是桥牌那种 13 张加复杂叫牌就得换动态规划了。所以best_knock既是计分工具也是 AI 的全部推理基础值得单独写成一个纯函数并配单元测试。4.2 出牌选择去掉一张牌后重新算死牌AI 每回合弃哪张牌最简单也最可靠的做法是“模拟”对每一张手牌假设把它弃掉对剩下的 9 张重新调用best_knock看哪张牌被弃之后死牌分最低。这张牌就是当前最该出的牌因为它保留了最好的牌型结构。def choose_discard(hand, opp_rank_counts): best_card, best_score None, 10**9 for c in hand: rest [x for x in hand if x ! c] _, dead best_knock(rest) score dead # 防守惩罚这张牌的点数正是对家频繁收集的喂出去风险高 score 4 * opp_rank_counts.get(c % 13, 0) if score best_score: best_card, best_score c, score return best_card参数说明opp_rank_counts是一个字典key 是点数value 是对家从弃牌堆摸走该点数的次数由 4.3 的观察模块维护。dead是去掉这张牌后 9 张牌的最小死牌分它越低说明这张牌越“孤立”弃掉它不伤牌型。4 * opp_rank_counts是防守权重对家越爱摸某个点数AI 越不该把这个点数弃出去。这套出牌策略有三个可调参数也是 AI 强度的旋钮参数作用调大后效果死牌分权重保留牌型的优先级AI 更倾向保牌型但可能忽略喂牌防守惩罚系数 4避免喂牌的程度AI 更保守但可能为了防守弃掉好牌敲桌阈值 10死牌分多少算可敲调成 7 会让 AI 更激进、更容易被反超实际调参时建议先固定死牌权重只调防守系数。系数从 0 开始慢慢加观察 AI 对家能不能在你不喂牌的情况下自己凑出牌型。系数大于 8 之后 AI 会开始弃掉高价值单牌去防一个并不存在的牌型反而变弱了。4.3 防守信号记住对家从弃牌堆摸了什么弃牌堆暴露的信息比手牌多得多。对家每次从弃牌堆摸走一张牌就等于公开告诉你“这张牌或它附近的牌是我需要的”。把这些观察累积起来就是最廉价的防守情报。class OpponentProfile: def __init__(self): self.rank_counts {} # 点数 - 被摸走次数 self.suit_counts {} # 花色 - 被摸走次数 def observe(self, card, from_discard): if not from_discard: return rank, suit card % 13, card // 13 self.rank_counts[rank] self.rank_counts.get(rank, 0) 1 self.suit_counts[suit] self.suit_counts.get(suit, 0) 1 def danger(self, card) - int: 返回这张牌喂给对家的风险分供 choose_discard 使用。 risk self.rank_counts.get(card % 13, 0) * 4 if self.suit_counts.get(card // 13, 0) 2: risk 3 return riskobserve只记录从弃牌堆摸的牌因为从牌堆摸的牌没有任何倾向性。danger的判定逻辑是点数命中一次加 4 分说明对家可能在凑同点数的一组同一花色被摸过两次以上追加 3 分说明对家可能在凑同花顺子。这个“花色两刀”的启发式很糙但实现成本极低实测下来已经能挡住 AI 连续给对家喂同一花色顺子的问题。防守信号的边界要清楚它只能防“明牌”防不了对家手里捏着一手牌型等着敲桌。真正强力的防守还得靠观察对家弃牌的频率和时机那是另一层更复杂的策略作为进阶方向放在最后一章提。5. 从重复计分到 AI 死循环5 条规则落地的避坑清单5.1 顺子检查把不同花色的牌接在一起现象手牌里有5S 6S 7Hfind_runs返回了一个包含这三张的“顺子”死牌分瞬间被算成 0AI 以为自己 Gin 了。原因实现时只按点数排序做连续判断完全没锁花色。5, 6, 7点数连续但7H花色不同从规则上根本不能成顺。解决find_runs必须先按花色分组在每一组内部再排点数、找连续段。回归测试里固定放一组跨花色的牌断言它不会产出任何顺子。另外排序 key 用(suit, rank)不要直接对cid排序。5.2 Gin 被算成普通敲桌少了 25 分现象AI 死牌分 0 敲桌计分结果却只有死牌差Gin 的 25 分加成消失了。原因finish_round里先判断了dead_other dead_knocker的反超分支。敲桌者死牌 0、对家死牌也 0 时这个条件成立程序错误地走了反超分支把 25 分判给了对家。解决把if dead_knocker 0放在最前面Gin 分支优先返回。这类分支顺序 bug 靠肉眼很难发现最有效的办法是在测试里构造“敲桌者 Gin、对家死牌 0”的对称局面直接断言分数。5.3 A 的环回QKA 能接KA2 不能接现象有人把K A 2拼成了顺子还有人把Q K A判为非法。原因A 在编码里是 0既不是 1 也不是 14。两个实现极端一个把 A 当成万能牌去首尾相连另一个在检查连续时没处理好0作为最小值的边界。解决规则上明确 A 只能做低点KA2不算顺子QKA也不合法。实现时顺子检测只做“后一张点数等于前一张加 1”的升序判断不做任何环回处理然后加一个包含QA2的测试锁死行为。5.4 AI 反复摸同一张弃牌回合数不收敛现象AI 从弃牌堆摸走7S算完发现它不属于任何牌型又把它弃回弃牌堆下一回合又摸回来游戏永远结束不了。原因没有记录“这张牌是从弃牌堆摸来的”也没有实现“刚摸的弃牌本回合禁止弃回”这条规则。AI 打出的每个动作单独看都合法连起来就成了死循环。解决在GameState里加last_drawn_from_discard字段discard_card校验时拒绝弃掉这张牌。更稳妥的做法是把这条规则写进 Ai 决策的约束层而不是靠 AI 自觉。5.5 加了随机种子崩溃现场还是复现不了现象某次 AI 死循环的现场记下了seed42重跑却一切正常。原因初始化时random.shuffle(self.deck)用的是模块级全局随机源而 AI 内部又用了self.rng两个随机源互不同步seed只能复现洗牌复现不了后续 AI 的随机决策。解决所有随机调用统一走self.rngAI 决策里也绝不调用random模块的全局函数。再把每回合的关键动作按(seed, 回合号)写进日志崩溃时直接按这个 key 回放。加了种子还复现不了八成是随机源不统一这条血泪经验值得刻在项目 README 里。6. 用回归测试锁住规则从构造 Gin 手牌到洗牌公平性最后一公里是验证。金拉米规则细碎人工试玩永远试不全边界我一般会写三类回归测试计分测试、拆牌测试、随机公平性测试。计分测试直接构造一手必 Gin 的手牌用card_str把可读牌面写进断言里让测试本身可读可 review。def test_gin_scoring(): g GinRummyGame(seed7) # 两段顺子加一个四张组2S3S4S, 2H3H4H, 7S7H7D7C g.hands[0] [1, 2, 3, 14, 15, 16, 6, 19, 32, 45] assert card_str(1) 2S and card_str(45) 7C # 对家是一手无任何牌型的散牌死牌分固定为 70 g.hands[1] [3, 7, 11, 15, 21, 25, 30, 34, 41, 48] g.finish_round(knocker0) assert g.scores[0] 25 70 # Gin 加对家全部死牌分拆牌测试专挑共用牌的歧义局面比如5S6S7S和7S7H7D7C都用到7S正确拆分必须放弃其中一组断言最后算出的死牌分是 10 而不是 0。这类测试是防止后续重构把best_knock改坏的最有效保险。def test_overlap_meld_choice(): # 5S6S7S 7H7D7C 9HTHJH KC正确拆法死牌应为 KC 的 10 分 hand [4, 5, 6, 19, 32, 45, 21, 22, 23, 25] _, dead best_knock(hand) assert dead 10随机公平性测试用统计说话。跑 5000 局统计玩家 0 首牌里每张牌出现的次数期望值是5000 * 10 / 52 ≈ 962因为每次发牌相互独立波动幅度理论上限在 120 以内断言max(counts) - min(counts) 120就能抓住“两张牌永远挨在一起”这类洗牌 bug。这三个测试跑完规则层面的信心就立住了。剩下值得投入的方向是把 AI 从“会拆牌”升级到“会看人”记录对家弃牌的犹豫次数、敲桌时机、平均死牌分用来判断对家是保守型还是激进型然后动态调整敲桌阈值。我自己的习惯是每改一个规则先给finish_round写一个对应测试再动代码这个顺序帮我挡住了至少三次计分回归。希望帮到你。本文还有配套的精品资源点击获取