用Java复现金拉米:源码架构、规则引擎与AI策略解析
简介这是一份基于 Java 语言实现的杜松子酒接龙Gin Rummy双人纸牌对战资源适合希望学习 Java 游戏开发、面向对象设计以及简单 AI 策略的开发者阅读。压缩包共 163 个文件大小约 1.44MB主要内容包括 15 个 Java 源码、18 个编译后的 class 文件、3 个 jar 依赖库以及 108 个用于展示界面、操作演示或运行效果的 gif 图片另有 xml、properties 等配置与工程管理文件。从文件结构可以推测项目具备完整的卡牌对象建模、发牌与计分逻辑、机器人对手决策模块和基础 GUI 交互界面。目前已吸引 214 人学习或浏览。通过阅读这套源码开发者可以了解 Gin Rummy 的规则落地方式、Java 事件驱动下的人机交互流程以及如何在牌局中用算法模拟对手行为是一份适合用于课程设计、个人练手或游戏开发入门的完整 Java 小项目。1. 用 Java 复刻金拉米这份源码为什么值得跑起来翻遍网上能找到的 Java 纸牌项目绝大多数是斗地主或者 21 点Gin Rummy金拉米反而成了稀缺品。这项目难得之处在于它不是只贴了一个控制台模板而是把牌组、洗牌、手牌管理、meld 检测、算分以及一个能跟你真打几回合的 AI 全部用 Java 串起来。你下下来能直接编译运行能跟电脑对战也能把规则引擎单独抠出来做二次开发。如果你最近在写 Java 课程设计或者想找个「数据结构 算法 简单 AI 全都有」的练手项目这套源码比硬啃教材舒服得多。从代码量看它没有引入任何第三方依赖纯 JDK 完成核心逻辑集中在几十个类里。这意味着你不用配 Maven、不用处理复杂依赖拿到手就能读源码。接下来的拆解会从整体架构开始然后深入牌组、规则引擎和 AI 策略最后聊聊我在这类项目上踩过的坑。2. 技术架构与目录拆解从 App 入口到规则引擎的分层设计2.1 技术选型为什么用纯 JDK 而不是框架先明确一个前提这是个桌面风格的小型对战游戏不是 Web 服务。用 Java 做这类项目最自然的选型就是「模型层 逻辑层 表现层」三段式表现层既可以做成控制台也可以替换成 Swing 窗口这也是 Java 的优势——换 UI 不用动业务代码。整个项目不依赖 Spring、不依赖任何第三方库原因也好理解Gin Rummy 本身就是一套封闭的规则不需要连数据库不需要处理并发请求最吃重的是集合操作和算法逻辑。JDK 自带的 ArrayList、HashMap、Collections 完全够用引入框架反而拖慢启动、增加阅读成本。如果你将来想把这套引擎嵌进 Web 游戏模型和引擎部分直接复用即可表现层重写一版就行。2.2 目录结构一眼看清包的作用先看源码的整体布局我习惯通过包名判断项目是怎样的分层思路src/main/java/com/ginrummy/ ├── App.java // 程序入口负责初始化并启动对局 ├── model/ │ ├── Card.java // 单张扑克牌花色 点数 │ ├── Rank.java // 点数枚举包含 A 到 K │ ├── Suit.java // 花色枚举黑桃、红心、方块、梅花 │ ├── Deck.java // 一整副牌洗牌、摸牌、剩余张数 │ └── Hand.java // 玩家手牌加牌、排序、扑克牌列表 ├── engine/ │ ├── GameEngine.java // 对局流程编排回合、摸牌、弃牌、判定 │ ├── MeldDetector.java // 检索手牌中能构成的牌型set / run │ ├── Scorer.java // 计算 deadwood 点数和 │ └── KnockEvaluator.java // 判断当前手牌是否能 knock 或 gin ├── ai/ │ └── SimpleAI.java // 电脑玩家的出牌与弃牌策略 └── ui/ ├── ConsoleUI.java // 控制台界面当前默认入口 └── SwingUI.java // 图形界面的雏形可作为二次开发起点这个分层顺序其实就是一局游戏的数据流UI 层把玩家动作交给 GameEngineGameEngine 操作 model 层的 Deck 和 Hand在需要判定时调用 engine 包的规则类AI 则模拟玩家行为调用同样的接口。换句话说AI 和人类玩家在引擎眼里没有区别都只是「会做动作的决策者」。2.3 从 App 入口到一局游戏的启动链路App.java 是整个程序的起点它的职责很单一创建一副牌、创建两个玩家、初始化引擎然后进入回合循环。关键代码长这样public class App { public static void main(String[] args) { Deck deck new Deck(); deck.shuffle(); Hand playerHand new Hand(); Hand aiHand new Hand(); GameEngine engine new GameEngine(deck, playerHand, aiHand); engine.dealInitialCards(10); engine.startNewRound(); ConsoleUI ui new ConsoleUI(engine); ui.runGameLoop(); } }这段代码里值得注意的有几点。deck.shuffle()在发牌之前必须有否则每局对战都拿到顺序固定的手牌会严重影响测试体验dealInitialCards(10)是金拉米的标准规则每个玩家发 10 张ConsoleUI负责把引擎状态打印到控制台并接收玩家输入。如果你不想看控制台输出直接把最后一行换成new SwingUI(engine).setVisible(true)就能切成图形界面这正好说明 UI 层和逻辑层解耦得比较干净。2.4 怎么把项目跑起来命令行的两种方式如果你的环境里已经有 JDK 8 或更高版本不需要任何构建工具也能编译运行。在项目根目录执行# 编译全部源码-d 指定输出目录 javac -encoding UTF-8 -d out \ src/main/java/com/ginrummy/model/*.java \ src/main/java/com/ginrummy/engine/*.java \ src/main/java/com/ginrummy/ai/*.java \ src/main/java/com/ginrummy/ui/*.java \ src/main/java/com/ginrummy/App.java # 运行 java -cp out com.ginrummy.App-encoding UTF-8这个参数容易被人忽略下面第 5 章我会专门展开讲乱码问题这里先记住一点源码里如果有中文注释或中文提示不加这个参数在 Windows 上十有八九会编译报错。运行之后控制台会出现回合提示输入数字选择摸牌堆还是弃牌堆然后选择要丢弃的牌回合交替进行。3. 核心模块实战牌组、洗牌与手牌数据结构3.1 牌面模型Rank 与 Suit 枚举在 Gin Rummy 里每张牌的属性只有两个——花色和点数。用枚举比直接用整数更安全因为枚举把合法值限定死了不会出现点数 99这种脏数据。这是 Card 的配套枚举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 points; Rank(int points) { this.points points; } public int getPoints() { return points; } }这里参数points表达的是这张牌在 deadwood 结算时的面值。金拉米的规则是A 计 1 点2 到 10 按牌面计点J、Q、K 统一计 10 点。注意这套规则只影响算分不影响牌型里的大小顺序大小顺序由枚举声明顺序决定这也是为什么 ACE 放在最前面。3.2 Deck一副牌、洗牌、摸牌Deck 类内部维护一个ListCard构造时用嵌套循环生成 52 张牌。最关键的是洗牌方法public class Deck { private final ListCard cards; public Deck() { cards new ArrayList(); for (Suit suit : Suit.values()) { for (Rank rank : Rank.values()) { cards.add(new Card(rank, suit)); } } } public void shuffle() { Random random new Random(); for (int i cards.size() - 1; i 0; i--) { int j random.nextInt(i 1); Card temp cards.get(i); cards.set(i, cards.get(j)); cards.set(j, temp); } } public Card drawCard() { if (cards.isEmpty()) { throw new IllegalStateException(牌堆已空无法摸牌); } return cards.remove(cards.size() - 1); } }这里用的是标准的 Fisher-Yates 洗牌算法称为反向遍历 随机交换。从最后一个元素开始每次在[0, i]范围内随机选一个下标跟当前位置交换时间复杂度是 O(n)而且能保证每一张牌落在任意位置的概率都是等价的不要用每张牌和随机位置反复交换的笨办法那样会产生花色分布偏差。drawCard()从列表尾部取牌配合ArrayList的尾部删除是 O(1) 操作比从头删除快得多。3.3 Hand手牌的添加与排序手牌这个类的设计影响了后面规则引擎的检索效率。我见过不少实现直接在 Hand 里放一个ListCard然后用Collections.sort硬排但这套源码的处理更严谨——每次加牌后对天然顺序做增量排序public class Hand { private final ListCard handCards; public Hand() { handCards new ArrayList(); } public void addCard(Card card) { handCards.add(card); handCards.sort(Comparator .comparingInt((Card c) - c.getRank().ordinal()) .thenComparing(c - c.getSuit().ordinal())); } public boolean removeCard(Card card) { return handCards.remove(card); } public ListCard getCards() { return new ArrayList(handCards); } }排序的比较器先按点数枚举顺序排再按花色排这样打印出来的手牌一目了然点数从小到大同点数时按花色聚在一起。这种排列方式对第 4 章要讲的 run 检测非常友好因为判断顺子时只需要扫描相邻两张牌。注意getCards()返回的是复制列表防止外部拿到内部引用后随意修改手牌这是防御式编程的常见做法。3.4 回合流转怎么组织一场完整的对局GameEngine 是整场对局的调度中心管理摸牌堆、弃牌堆、当前回合玩家和回合状态。一回合的标准动作是先摸牌后弃牌摸牌可以选择从牌堆顶摸一张也可以从弃牌堆顶部取一张——后者在金拉米里还有一个附加限制如果你取了弃牌堆的牌就不能在同回合丢掉它。这个限制直接由引擎校验public boolean canDiscard(Card card) { if (justDrewFromDiscardPile) { return !card.equals(lastDrawnCard); } return true; }justDrewFromDiscardPile这个标志位保证了一个完整回合里的因果关系玩家刚拿到的牌不能立刻又丢回弃牌堆。这类细节是最容易翻车的新手往往忽略了金拉米中「从弃牌堆拿的牌不能原样丢回」的规则导致玩家可以无限循环从弃牌堆拿同一张牌。引擎在每个动作前检查合法性比在 UI 层判断要安全得多。4. 规则引擎与 AImeld 检测、算分和出牌逻辑4.1 meld 检测找出牌型中的 set 与 run金拉米的核心目标是把 10 张手牌编组成若干牌型meld。牌型分两类set 是点数相同、花色各异的至少 3 张牌run 是同花色、点数连续的至少 3 张牌。检索所有可能牌型是规则引擎最复杂的部分暴力枚举复杂度很高但手牌只有 10 张状态空间有限用分组扫描足够。public class MeldDetector { public ListMeld detectMelds(ListCard hand) { ListMeld result new ArrayList(); result.addAll(detectSets(hand)); result.addAll(detectRuns(hand)); return result; } private ListMeld detectSets(ListCard hand) { ListMeld melds new ArrayList(); MapRank, ListCard grouped new HashMap(); for (Card card : hand) { grouped.computeIfAbsent(card.getRank(), k - new ArrayList()).add(card); } for (Map.EntryRank, ListCard entry : grouped.entrySet()) { if (entry.getValue().size() 3) { melds.add(new Meld(entry.getValue())); } } return melds; } private ListMeld detectRuns(ListCard hand) { ListMeld melds new ArrayList(); MapSuit, ListCard bySuit new HashMap(); for (Card card : hand) { bySuit.computeIfAbsent(card.getSuit(), k - new ArrayList()).add(card); } for (ListCard suitCards : bySuit.values()) { suitCards.sort(Comparator.comparingInt(c - c.getRank().ordinal())); int startIndex 0; for (int i 1; i suitCards.size(); i) { if (i suitCards.size() || suitCards.get(i).getRank().ordinal() ! suitCards.get(i - 1).getRank().ordinal() 1) { if (i - startIndex 3) { melds.add(new Meld(suitCards.subList(startIndex, i))); } startIndex i; } } } return melds; } }detectSets 的逻辑是「按点数分组组内达到 3 张即算牌型」。detectRuns 稍绕一些先把同一花色的牌聚在一起按点数排序后用双指针扫描连续段当发现断点时检查当前连续段长度是否达到 3。startIndex记录连续段的起点一旦中断就结算这段区间。两个方法的共同优势是都避免了对所有组合的全排列枚举而是利用数据结构天然的分组性质剪枝这对 10 张手牌规模来说足够高效。4.2 算分deadwood 点数和与 knock 判定meld 检测完成后Scorer 把那些没进入任何牌型的剩余牌点数和算出来这就是 deadwood。规则要求当 deadwood 点数不大于 10 时玩家可以选择 knock摊牌如果 10 张牌全部组成了 meld则直接 gin不需要弃牌而且自动获得 25 分奖励。public class Scorer { public int calculateDeadwood(ListCard hand, ListMeld melds) { SetCard meldedCards new HashSet(); for (Meld meld : melds) { meldedCards.addAll(meld.getCards()); } int deadwoodPoints 0; for (Card card : hand) { if (!meldedCards.contains(card)) { deadwoodPoints card.getRank().getPoints(); } } return deadwoodPoints; } public Winner evaluateKnock(Hand playerHand, Hand aiHand, ListMeld playerMelds, ListMeld aiMelds, boolean isGin) { int playerDeadwood calculateDeadwood(playerHand.getCards(), playerMelds); int aiDeadwood calculateDeadwood(aiHand.getCards(), aiMelds); if (isGin || playerDeadwood aiDeadwood) { return Winner.PLAYER; } return Winner.AI; } }calculateDeadwood利用HashSet的 O(1) 查找来判断一张牌是否被收进牌型10 张牌的遍历成本极低。evaluateKnock 里暗含一个容易忽略的规则knock 后如果对手的 deadwood 点数不高于你对手不仅能抵消差额还能额外获得 25 分的下庄奖励这让无脑 early knock 成为高风险动作。我在改造这套引擎时在这里加了日志输出能看到每回合 deadwood 的涨跌对调试 AI 策略帮助很大。4.3 knocks 决策与简单 AI 的弃牌策略这个项目自带的 AI 并不复杂但它的决策逻辑正好展示了一个最小可玩的策略模板。AI 每次行动分两步评估手牌能否 knock然后挑选一张「最不值得留」的牌丢掉。public class SimpleAI { private final MeldDetector meldDetector new MeldDetector(); private final Scorer scorer new Scorer(); public Card decideDiscard(ListCard hand) { ListMeld melds meldDetector.detectMelds(hand); int deadwood scorer.calculateDeadwood(hand, melds); if (deadwood 10) { return null; // 表示触发 knock } Card worstCard null; int worstValue Integer.MIN_VALUE; for (Card card : hand) { ListCard without new ArrayList(hand); without.remove(card); ListMeld remainingMelds meldDetector.detectMelds(without); int remainingDeadwood scorer.calculateDeadwood(without, remainingMelds); int meldPotentialLoss countMeldCards(hand) - countMeldCards(without); int score (deadwood - remainingDeadwood) * 2 - meldPotentialLoss * 5; if (score worstValue) { worstValue score; worstCard card; } } return worstCard; } }这个decideDiscard的逻辑核心是「把这个牌拿走之后deadwood 能降多少」以及「会拆掉多少现有牌型」两个指标的加权权衡。(deadwood - remainingDeadwood)计算弃掉该牌的直接收益如果一张孤立的高点牌去掉后 deadwood 立刻降 7 点它的得分就很高meldPotentialLoss则是惩罚项弃牌别拆散已经形成的顺子或三条。AI 每回合扫描全部 10 张牌每次都重新跑一遍 meld 检测逻辑上有点重但对于每回合一次的频率完全可以接受。这就是为什么这个 AI 看起来不聪明但也不会乱打——它具备基本的风险感知能力。5. 避坑指南编译运行与对战流程中的五个常见问题5.1 编译报错「找不到文件」通配符的锅现象按网上教程用javac -d out src/main/java/**/*.java编译结果提示找不到文件或者只编译了一个空包。原因javac 的通配符处理跟 shell 不一样**是 shell 的扩展语法但 Windows 的 cmd 和部分 Linux 环境变量不会递归展开src/main/java/**/*.javajavac 拿到的是字面路径自然找不到文件。解决要么像第 2 章那样把所有包路径一个一个列出来要么在 bash 里先用find生成文件清单再编译find src/main/java -name *.java sources.txt javac -encoding UTF-8 -d out sources.txtsources.txt是 javac 的 参数文件语法它会按文件列表逐一读取比手写清单更不容易漏文件。5.2 控制台中文提示变成乱码现象运行后菜单、提示信息全是问号或者乱码牌面完全看不懂。原因源码里的中文字符串是以 UTF-8 编码保存的但 Windows 默认控制台代码页是 GBK编译和运行时存在两处编码不一致。解决编译时加-encoding UTF-8运行时不加参数的话在 Java 17 之前还需要在 IDE 或者控制台设置-Dfile.encodingUTF-8。最省事的方式是给运行命令补全java -Dfile.encodingUTF-8 -cp out com.ginrummy.App从那以后我每次跑这类带中文的 Java 小项目都会强制走一遍「编译带 encoding、运行带 file.encoding」的组合没再被乱码折磨过。5.3 每次开局牌序完全相同Random 的种子问题现象连玩三局摸到的前 5 张牌一模一样洗牌形同虚设。原因Deck 构造函数里用了new Random()在 Java 中无参 Random 以当前时间戳做种子理论上不会重复但如果你测试时手动指定了固定种子或者在某些快速循环里创建多个 Deck确实可能拿到相同序列。解决洗牌逻辑不要依赖new Random()的隐式种子给shuffle()补充一个可注入的种子参数方便测试复现对局中用默认时间种子即可保持随机性。排查时可以把开局前 10 张牌打印出来比较如果完全相同优先检查是否有固定种子在作怪。5.4 弃牌时抛 ConcurrentModificationException现象AI 在decideDiscard里遍历 hand 并调用remove运行到一半直接抛异常。原因for (Card card : hand)这种 foreach 遍历底层用的是迭代器遍历期间直接对原列表增删元素会让迭代器检测到结构被修改。解决遍历时不要直接操作原集合。正确姿势是先复制一份再遍历或者用迭代器的remove()方法。前文 AI 代码里我用的是new ArrayList(hand)拿副本这就是为了避免这个坑。如果你要优化性能可以改成正序遍历 list.remove(index)但前提是确认不会跳过相邻元素。5.5 A 牌到底是 1 点还是 15 点边界规则要摊开现象有同学玩的时候发现 A 可以接在 K 后面组成顺子算分时按 10 点算结算结果和标准规则对不上。原因金拉米规则在不同版本里对 A 的处理方式有分歧。有的玩法允许 A-2-3 作为最小顺子K-A-2 这种环形顺子则不允许点数上 A 又固定是 1 点。解决这套源码的实现是「A 只能作为小牌使用点数固定 1 点」这是最主流的规则。如果你接手后想改成 A 可以两头接既当 1 也当 14需要同时改 Rank 枚举的排序接口和 MeldDetector 的连续段判断。改的时候注意两点——排序比较器里ACE的 ordinal 别挪否则所有顺子检测都会错乱算分函数getPoints()要单独保留ACE(1)不变因为点数和牌型顺序是两个独立逻辑。6. 二次开发给金拉米换皮肤、加难度、加新打法6.1 把控制台换成 Swing 桌面界面现在引擎和 UI 解耦得比较好换界面不用动规则代码。需要做的是写一个JFrame把Hand渲染成牌面按钮把弃牌堆顶部和摸牌堆剩余数量绘制在两侧。核心的触发逻辑只有一个点弃牌堆 调用engine.drawFromDiscardPile()点摸牌堆 调用engine.drawFromDeck()选中手牌后点弃牌 调用engine.discard(selectedCard)。验证切 UI 是否成功不只看窗口弹出来还要检查三个动作的合法性校验仍然生效——特别是「从弃牌堆拿的牌不能立刻丢掉」这个限制在图形界面里很容易漏做置灰处理。6.2 加一个「提示」按钮显示当前最优弃牌你可以把 AI 的decideDiscard直接复用为玩家的助手功能新增一个按钮点击后调用 AI 类的方法把计算出的弃牌建议高亮显示。这个改造约等于零成本因为 AI 和玩家的手牌都是Hand对象接口完全一致。验证方法也别只看建议合不合理建议每回合把该建议对应的 deadwood 降幅打印出来这样能直观判断提示的参考价值。6.3 升级 AI让对手记住你弃过什么牌原版 AI 每次只盯着自己的手牌完全无视弃牌堆里的牌和玩家出过的牌。一个低成本的加强思路给 SimpleAI 加一个ListCard opponentDiscards字段在每回合更新。策略调整成两档——如果某张牌出现在玩家弃牌堆里超过 2 次AI 推断你不太可能收集这类牌于是提高这张牌的弃牌优先级相反如果 AI 发现玩家连续弃了 3 张黑桃它会反过来推断你大概率在打红心或者方块对自己手里的黑桃降低保留价值。这套记忆逻辑不到 50 行但对 AI 的观感提升非常明显。6.4 验证改造效果的两种硬手段改完内存逻辑后不要光靠人机对战的主观体感。一个可靠办法是写一个简单的命令行自动对局让两个SimpleAI互打 1000 局统计先手和 AI 的胜率分布以此验证改动没有破坏基本的回合流转。另一个办法是给 Scorer 补一个单元测试构造一副已知手牌手动算出期望 deadwood断言calculateDeadwood的结果完全一致保证你在改 MeldDetector 时没有伤到算分逻辑。说起这个我以前接过一个卡牌项目就是因为我偷懒不改验证只改逻辑结果把顺子判断写漏了导致 AI 明明有三张连续牌却拼命弃牌那种「看起来正常但行为诡异」的 bug 最费时间。从那以后我每次给规则引擎动刀都会先强制跑一遍对局统计和算分单测确认数据没有异常波动再谈体验优化。希望这个习惯也能帮你在动手改造时少踩几个坑。本文还有配套的精品资源点击获取