
简介《Gin-Rummy-Card-Game》是一份基于Java实现的杜松子酒接龙纸牌游戏源码项目适合正在学习Java面向对象编程、游戏逻辑设计或简单AI策略的开发者阅读与二次开发。项目完整实现了经典Gin Rummy双人对战规则并包含一个内置决策算法的机器人对手可帮助理解洗牌发牌、手牌组合匹配、计分判定等核心流程。资源包共163个文件以gif演示、class编译产物、java源码及xml配置为主附带jar依赖库与项目说明文档压缩包大小约1.44MB整体结构清晰便于直接导入开发环境对照分析。已有214人学习下载。通过研读源码可以掌握Java Swing界面搭建、对象建模、回合制流程控制及基于评估函数的机器人出牌策略是一份兼具教学与实战参考价值的入门级游戏项目。1. 还没写好 UI先别急着写界面Gin Rummy 的胜负手藏在弃牌堆里接触 Gin Rummy 的 Java 实现时多数人的第一反应是做一套能玩的纸牌界面。真正动手拆过一遍后你会发现这个项目的硬骨头根本不在 Swing 绘牌或鼠标拖拽而在于规则引擎和 AI 决策。Gin Rummy 是两人零和纸牌游戏目标是将手牌组成尽可能多的 meld同花顺或刻子让剩余的 deadwood 点数最小化。这份 Java 课程设计资源正好覆盖了核心规则引擎、回合状态机、AI 对手与记分系统适合正在找 Java 练手项目的开发者或者想在数据结构和算法层面做些有意思实践的读者。它的价值不在于界面多华丽而在于你能否用面向对象把这套牌型检测和 AI 评估逻辑写清楚——这才是真正值得下载复现的部分。2. 规则引擎与牌型建模先让烂牌输得明白Gin Rummy 的规则引擎是整个项目的地基。Java 的强类型特性在这里非常加分——把牌、手牌、meld、回合这些概念用类结构显式建模比用数组加魔法数字靠谱得多。2.1 牌组与枚举从 Rank 和 Suit 开始建模一份标准的 52 张牌在 Java 里通常拆成两个枚举Suit花色和Rank点数。这里要注意 Gin Rummy 的点数与 deadwood 分并不等同——A 在顺子里算低牌但在计分时是 1 分K、Q、J 各自是 10 分数字牌按面值计分。public enum Rank { ACE(1), TWO(2), THREE(3), FOUR(4), FIVE(5), SIX(6), SEVEN(7), EIGHT(8), NINE(9), TEN(10), JACK(10), QUEEN(10), KING(10); private final int deadwoodValue; Rank(int deadwoodValue) { this.deadwoodValue deadwoodValue; } public int getDeadwoodValue() { return deadwoodValue; } }这段代码把「牌面顺序」和「计分价值」解耦了。ACE的 ordinal 是 0在顺子判断中它应该接在TWO前面而不是KING后面——这是个极其容易翻车的细节。死木deadwood的计算逻辑就是遍历手牌中不属于任何 meld 的牌累加它们的deadwoodValue。如果你的手牌有 10 张其中 3 张组了刻子、4 张组了顺子剩下的 3 张落单那 deadwood 就是这 3 张的点数之和。发牌阶段用Collections.shuffle()洗牌即可但强烈建议在构造Deck时传入一个Random实例而不是让shuffle()内部默认创建。原因在第 5 章避坑里会详细展开这里先记住Java 的Random如果不给种子每次运行结果确实不同但如果多个实例几乎同时创建可能拿到相同的种子导致两局洗牌结果一模一样。2.2 meld 检测这是整个项目最容易写错的地方meld 分为两种——同花顺run和三张或四张同点数set。检测算法不复杂但组合起来容易出边界问题。常见做法是把手牌按两个维度排序先按点数排再按花色排。public static ListListCard findMelds(ListCard hand) { ListListCard result new ArrayList(); // 先找 set按 Rank 分组大于等于 3 张构成刻子 MapRank, ListCard byRank hand.stream().collect(Collectors.groupingBy(Card::getRank)); for (Map.EntryRank, ListCard entry : byRank.entrySet()) { if (entry.getValue().size() 3) { result.add(new ArrayList(entry.getValue())); } } // 再找 run按花色分组同花色内排好序找出连续段 MapSuit, ListCard bySuit hand.stream() .sorted(Comparator.comparing(Card::getRank)) .collect(Collectors.groupingBy(Card::getSuit)); for (ListCard suitCards : bySuit.values()) { ListCard currentRun new ArrayList(); for (int i 0; i suitCards.size(); i) { if (currentRun.isEmpty()) { currentRun.add(suitCards.get(i)); } else { Card prev currentRun.get(currentRun.size() - 1); if (suitCards.get(i).getRank().ordinal() prev.getRank().ordinal() 1) { currentRun.add(suitCards.get(i)); } else { if (currentRun.size() 3) { result.add(new ArrayList(currentRun)); } currentRun.clear(); currentRun.add(suitCards.get(i)); } } } if (currentRun.size() 3) { result.add(new ArrayList(currentRun)); } } return result; }逻辑说明先按Rank分组找 set再用groupingBy(Suit)把同花色牌聚在一起排好序后做一次线性扫描找连续段。找到的 meld 列表只用于后续的 deadwood 计算和 AI 评估不要直接当作玩家最终提交的 meld——因为实际出牌时玩家还要考虑同一张牌可能同时适合多个 set 或 run这时候需要做组合取舍。这里有一个关键参数的坑ACE的 ordinal 是 0而TWO是 1所以在顺子检测里 A-2-3 能正确连上但 K-A-2 会被断开——这是正确的 Gin Rummy 行为A 不能作环绕。如果你把 A 的 ordinal 放到 13那 A-2-3 就检测不出来了。2.3 deadwood 计算与最优 meld 组合贪心不够就回溯找到所有可能的 meld 之后下一步是选出能让 deadwood 最小的那组 meld。这里有个常见的错误做法把找到的所有 meld 一股脑全算进melded集合忽略牌的重叠问题。比如你有三张 7 和 7-8-9 的红桃顺子其中那张红桃 7 只能属于一组你不能既把它算进刻子又算进顺子。我一般会用一个回溯算法来穷举所有 meld 组合选择 deadwood 最小的方案。手牌只有 10 张melds 的数量有限回溯的规模完全可控。public static int minimalDeadwood(ListCard hand, ListListCard melds) { int[] best { Integer.MAX_VALUE }; backtrack(hand, melds, 0, new boolean[hand.size()], best); return best[0]; } private static void backtrack(ListCard hand, ListListCard melds, int index, boolean[] used, int[] best) { if (index melds.size()) { int deadwood 0; for (int i 0; i hand.size(); i) { if (!used[i]) { deadwood hand.get(i).getRank().getDeadwoodValue(); } } best[0] Math.min(best[0], deadwood); return; } // 不选当前 meld backtrack(hand, melds, index 1, used, best); // 尝试选当前 meld——检查是否与已选牌冲突 ListCard meld melds.get(index); boolean conflict false; for (Card card : meld) { for (int i 0; i hand.size(); i) { if (used[i] hand.get(i).equals(card)) { conflict true; break; } } if (conflict) break; } if (!conflict) { for (Card card : meld) { for (int i 0; i hand.size(); i) { if (hand.get(i).equals(card)) { used[i] true; break; } } } backtrack(hand, melds, index 1, used, best); // 回溯撤销 for (Card card : meld) { for (int i 0; i hand.size(); i) { if (hand.get(i).equals(card)) { used[i] false; break; } } } } }逻辑说明backtrack对每个 meld 做「选 / 不选」的二叉分支used数组记录手牌中哪些牌已经被 meld 占用。当处理完所有 meld 后对used false的牌累加deadwoodValue得到当前方案的 deadwood。参数index表示当前处理到第几个 meldbest用一元素数组持有全局最优值。注意equals一定要同时比较 Rank 和 Suit否则会出现 A 的红桃和黑桃互相覆盖的离谱问题。3. 回合状态机与弃牌堆让游戏流程不再到处散落 if很多写着写着就翻车的项目是因为把「当前轮到谁」「能不能抽牌」「能不能 knock」这些判断散落在各个按钮的事件回调里。Gin Rummy 项目正确做法是用一个显式的状态机管理回合流转。3.1 Phase 枚举与核心状态转移回合可以拆成四个阶段DRAW从牌堆或弃牌堆抽牌、DISCARD弃一张牌、KNOCK_DECISION决定是否 knock 或 gin、ROUND_END结算本轮。用枚举做主状态配合当前玩家指针整个流程立刻清晰很多。public enum Phase { DRAW, DISCARD, KNOCK_DECISION, ROUND_END }每回合开始时处于DRAW玩家抽完牌进入DISCARD。弃牌后系统检查玩家手牌——若 deadwood 小于等于 10 可触发 knock。实际项目中我建议把这步也做成状态而非在按钮回调里直接判断因为 AI 对手同样要走这套逻辑。转移表当前状态动作下一状态DRAW从牌堆抽一张DISCARDDRAW从弃牌堆顶抽一张DISCARDDISCARD弃掉一张手牌KNOCK_DECISIONKNOCK_DECISION不 knock回到 DRAW下一玩家回合KNOCK_DECISIONdeadwood ≤ 10knockROUND_ENDKNOCK_DECISIONdeadwood 0ginROUND_ENDROUND_END结算完成重置到新一轮 DRAW这里有一个容易被忽略的规则玩家只能从弃牌堆顶拿牌不能拿中间任意一张。这是弃牌堆信息博弈的核心——你把一张牌弃到顶上等于给对手传递信息同时也在限制对手拿到的牌。界面上如果做的是点击弃牌堆任意一张牌就能抽那规则就错了。3.2 合法动作校验宁可多写一个方法不要信点击顺序在一个回合里玩家是否能从牌堆抽牌、是否能从弃牌堆抽牌、弃牌后能否 knock都是需要严格校验的。我一般会把这些逻辑统一收敛到isLegalAction(Action action)里而不是散落在界面里。public boolean isLegalAction(Action action) { switch (phase) { case DRAW: return action Action.DRAW_FROM_STOCK || action Action.DRAW_FROM_DISCARD; case DISCARD: return action Action.DISCARD_CARD; case KNOCK_DECISION: return action Action.KNOCK || action Action.PASS; default: return false; } }逻辑说明每个 Phase 只放行合法动作。DRAW_FROM_STOCK和DRAW_FROM_DISCARD是两个独立动作即便效果都是给玩家加一张牌也要分开——因为 AI 读取弃牌堆信息时必须知道对手是从哪抽的。参数里Action枚举定义四个值DRAW_FROM_STOCK、DRAW_FROM_DISCARD、DISCARD_CARD、KNOCK、PASS。严格校验的好处是 UI 层无论怎么乱点状态机不会被带偏。这里补充一个细节从弃牌堆抽牌后弃牌堆顶部的位置就空出来了下一张弃牌会覆盖上去。实现时不需要维护一个复杂的链表用一个DequeCard或者直接ArrayListCard的末尾当栈顶就够用。抽牌时pop()弃牌时push()简单直接。4. 智能 AI 对手从贪心评估到隐约的博弈预判单机版 Gin Rummy 最核心的可玩性来自 AI。如果一个 AI 只会无脑保留高点数牌玩家三局就会腻。这个项目里 AI 的实现从评分函数出发按难度分了三档。4.1 手牌评分函数把 meld 和 deadwood 变成可比较的数字AI 评价一手牌的好坏需要一个标准方程。常见做法是score meldSize * MELD_WEIGHT - deadwood * DEADWOOD_PENALTY或直接只用 deadwood 做反向排序。但只用 deadwood 有两个盲区不考虑 meld 的拓展潜力不考虑弃牌堆暴露的信息。所以我更倾向于用两个维度当前 deadwood 和「潜在 meld 数量」。public double evaluateHand(PlayerHand hand) { ListListCard melds MeldsDetector.findMelds(hand.getCards()); int deadwood DeadwoodCalculator.minimalDeadwood(hand.getCards(), melds); int potentialMelds countPotentialMelds(hand.getCards()); return -deadwood * 1.5 potentialMelds * 0.8; }逻辑说明findMelds拿到当前已成型的 meldminimalDeadwood算出最小散牌点数和countPotentialMelds统计「差一张牌就成 meld」的候选组。三者合成一个总分AI 在弃牌时选择让手牌评估分数最高的弃牌方案。参数1.5和0.8是权重——deadwood 权重高意味着 AI 更保守宁可拆掉潜力顺子也要尽快降低散牌potentialMelds权重高则 AI 更激进愿意为等一张牌多背几轮 deadwood 风险。4.2 三档难度新手、均衡、激进新手难度只需要做到「抽到牌后弃掉 deadwood 点数最高的牌」。均衡难度考虑「弃牌后手牌评估分最高」。激进难度则会额外做一步——记忆弃牌堆里出现过的牌估算对手可能需要的牌优先扣住对手可能等到的牌、弃掉对手已弃过的花色。public Card chooseDiscard(PlayerHand hand, DiscardPile discardPile) { Card bestCard null; double bestScore Double.NEGATIVE_INFINITY; for (Card card : hand.getCards()) { PlayerHand temp hand.clone(); temp.removeCard(card); double score evaluateHand(temp); if (difficulty Difficulty.HARD discardPile.opponentAvoided(card.getSuit())) { score 0.5; } if (score bestScore) { bestScore score; bestCard card; } } return bestCard; }逻辑说明遍历手牌中每一张模拟「弃掉它之后」的评估分数选最高者。clone必须做深拷贝——如果removeCard直接改原手牌AI 的决策循环会越算越乱。做opponentAvoided的目的是让 AI 倾向于弃掉对手已经放弃的花色降低喂牌概率——这是一条真实有效的 Gin Rummy 经验而不是玄学。实现到最后你会发现三档难度本质是同一个评估函数的三个参数配置难度deadwood 权重potentialMelds 权重弃牌堆分析新手1.00.2关闭均衡1.50.8关闭激进2.00.5开启激进档把potentialMelds压低是因为它更重视当下死木分——扣住对手可能需要的牌本身就是一种防守这种打法更适合高风险对局。新手档potentialMelds权重低但死木权重也低AI 会有更多随机性玩家体验不至于被吊打。5. 避坑指南:四个翻车现场与修复方案我拆过的 Java 纸牌项目十个里有八个死在这四个坑上。每条都是真实出现过的翻车记录按「现象 → 原因 → 解决」的顺序写方便你对照排查。5.1 洗牌结果连续两局完全一样现象每次启动游戏洗牌后的牌序都一样用户以为游戏是假的。原因Collections.shuffle()会调用new Random()获取随机源。如果两个Random实例在极短时间内创建系统时间种子相同生成的随机序列就会完全一致。尤其在一个方法里连续创建多个Deck对象时几乎必现。解决自己持有Random实例并显式传入shuffle(list, random)在程序启动时用new Random()创建一次全局复用如果仍担心可以混入毫秒级时间戳或一局编号做种子扰动。5.2 deadwood 计算重复计入同一张牌现象手牌 10 张里包含了红桃 7、梅花 7、方块 7 和红桃 8、红桃 9deadwood 计算结果为 0。原因findMelds找到了两个 meld——三张 7 的刻子和红桃 7-8-9 的顺子它们共享红桃 7。有些实现直接遍历所有 meld把成员全部标记为「已用」于是红桃 7 被重复计入。解决做组合选择时必须用回溯或穷举不能贪心。上面第 2.3 节的backtrack就是为此设计的核心是used数组标记每一张物理牌的使用状态而不是用牌面值去重。5.3 AI 抽牌后永远弃掉刚抽的牌现象AI 从弃牌堆抽走一张牌紧接着又把它丢回弃牌堆行为看着很蠢。原因chooseDiscard遍历时把抽到的新牌和其他手牌一视同仁地放入候选集评估分算出来它最差就弃了它——这在逻辑上「可以但没必要」而且会让对手轻易拿回牌。解决chooseDiscard需要在评估前排除刚抽入的牌或者在索引 0 处放置抽入牌并跳过。更稳定的做法是在PlayerHand中维护drewThisTurn标记弃牌阶段不允许弃出这张牌除非它形成 meld 且弃牌后手牌 deadwood 仍不劣化。5.4 顺子检测漏掉 A-2-3 或把 Q-K-A 当成有效顺子现象手牌有 A、2、3 同花色findMelds的 run 检测没识别出来另一个案例是 Q、K、A 同花色被错误识别成 run。原因Rank枚举的 ordinal 是 A0, 21, …, K12。如果检测代码用current.getRank() 1 next.getRank()这种方式并依赖 ordinal会出现两种错误A-2-3 的 ordinal 差为 1 应该能连上而 Q-K-A 差为 1 也会误判。问题往往出在有人手动把 A 改到 K 后面ordinal13于是 A-2-3 反而断掉。解决保持 A 的 ordinal 为 0并在检测时明确约定不能环绕。判断逻辑写成next.ordinal() prev.ordinal() 1 next ! ACE同时在ACE处单独判断prev ACE next TWO才能继续。不要试图让Rank同时满足「计数顺序」和「环绕顺序」两个用途要拆成不同方法。6. 打磨技巧用日志驱动的方式验证 AI 决策到底合不合理资源里给的 AI 和规则都能跑但跑起来未必对。我在复现时最常用的一招是给核心决策打详细的日志然后跑一整局 AI 自对弈用main方法直接输出决策轨迹。这个习惯帮我揪出了不少「明明测试用例通过、真人对局却总赢不了」的隐性 bug。具体做法是加一个日志开关在chooseDiscard、knock判定、minimalDeadwood返回处各打印一行关键数据。public static void main(String[] args) { Game game new Game(41L); game.addLogger((line) - System.out.println(line)); for (int round 0; round 50; round) { game.playRound(); } System.out.println(Player wins: game.getPlayerWins()); System.out.println(AI wins: game.getAIWins()); System.out.println(Avg rounds per game: game.getTotalRounds() / 50.0); }逻辑说明给Game类传入一个固定的随机种子41L方便复现某一局的具体路径。addLogger注入一个消费者接口所有决策点通过它输出日志。循环跑 50 轮统计胜负和平均回合数用来检验 AI 的稳定性。这里注意把随机种子固定下来——如果你每次都跑随机种子出了 log 这也无法复现等于白打。playRound()内部按状态机推进直到有玩家knock或牌堆抽空。平均回合数如果过低说明 AI 弃牌太随意容易让对手快速 gin如果过高说明 AI 过度等待需要调高 deadwood 权重。我还习惯在日志开关里加一个「人机对照」模式两个 AI 以不同难度互打全程打印每次决策的评估分数、弃牌选择理由。看到激进 AI 在什么局面下选择扣牌、什么局面下主动 knock你对评估函数参数的理解会瞬间清晰。从那次以后我每次调 AI 权重都强制走一遍「50 轮 AI 自对弈 日志复核」这套流程比单测用例更能暴露策略层的逻辑漏洞。希望这份拆解能帮你少走几个弯路。本文还有配套的精品资源点击获取