☰
用Java复现金拉米:源码架构、规则引擎与AI策略解析
2026/10/16 3:30:04 网站建设 项目流程

简介:这是一份基于 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 层把玩家动作交给 GameEngine,GameEngine 操作 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 类内部维护一个List<Card>,构造时用嵌套循环生成 52 张牌。最关键的是洗牌方法:

public class Deck { private final List<Card> 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 里放一个List<Card>然后用Collections.sort硬排,但这套源码的处理更严谨——每次加牌后对天然顺序做增量排序:

public class Hand { private final List<Card> 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 List<Card> 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. 规则引擎与 AI:meld 检测、算分和出牌逻辑

4.1 meld 检测:找出牌型中的 set 与 run

金拉米的核心目标是把 10 张手牌编组成若干牌型(meld)。牌型分两类:set 是点数相同、花色各异的至少 3 张牌;run 是同花色、点数连续的至少 3 张牌。检索所有可能牌型是规则引擎最复杂的部分,暴力枚举复杂度很高,但手牌只有 10 张,状态空间有限,用分组扫描足够。

public class MeldDetector { public List<Meld> detectMelds(List<Card> hand) { List<Meld> result = new ArrayList<>(); result.addAll(detectSets(hand)); result.addAll(detectRuns(hand)); return result; } private List<Meld> detectSets(List<Card> hand) { List<Meld> melds = new ArrayList<>(); Map<Rank, List<Card>> grouped = new HashMap<>(); for (Card card : hand) { grouped.computeIfAbsent(card.getRank(), k -> new ArrayList<>()).add(card); } for (Map.Entry<Rank, List<Card>> entry : grouped.entrySet()) { if (entry.getValue().size() >= 3) { melds.add(new Meld(entry.getValue())); } } return melds; } private List<Meld> detectRuns(List<Card> hand) { List<Meld> melds = new ArrayList<>(); Map<Suit, List<Card>> bySuit = new HashMap<>(); for (Card card : hand) { bySuit.computeIfAbsent(card.getSuit(), k -> new ArrayList<>()).add(card); } for (List<Card> 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(List<Card> hand, List<Meld> melds) { Set<Card> 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, List<Meld> playerMelds, List<Meld> 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(List<Card> hand) { List<Meld> 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) { List<Card> without = new ArrayList<>(hand); without.remove(card); List<Meld> 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/**/*.java,javac 拿到的是字面路径,自然找不到文件。

解决:要么像第 2 章那样把所有包路径一个一个列出来,要么在 bash 里先用find生成文件清单再编译:

find src/main/java -name "*.java" > sources.txt javac -encoding UTF-8 -d out @sources.txt

@sources.txt是 javac 的 @ 参数文件语法,它会按文件列表逐一读取,比手写清单更不容易漏文件。

5.2 控制台中文提示变成乱码

现象:运行后菜单、提示信息全是问号或者乱码,牌面完全看不懂。

原因:源码里的中文字符串是以 UTF-8 编码保存的,但 Windows 默认控制台代码页是 GBK,编译和运行时存在两处编码不一致。

解决:编译时加-encoding UTF-8,运行时不加参数的话,在 Java 17 之前还需要在 IDE 或者控制台设置-Dfile.encoding=UTF-8。最省事的方式是给运行命令补全:

java -Dfile.encoding=UTF-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 加一个List<Card> opponentDiscards字段,在每回合更新。策略调整成两档——如果某张牌出现在玩家弃牌堆里超过 2 次,AI 推断你不太可能收集这类牌,于是提高这张牌的弃牌优先级;相反,如果 AI 发现玩家连续弃了 3 张黑桃,它会反过来推断你大概率在打红心或者方块,对自己手里的黑桃降低保留价值。这套记忆逻辑不到 50 行,但对 AI 的观感提升非常明显。

6.4 验证改造效果的两种硬手段

改完内存逻辑后,不要光靠人机对战的主观体感。一个可靠办法是写一个简单的命令行自动对局,让两个SimpleAI互打 1000 局,统计先手和 AI 的胜率分布,以此验证改动没有破坏基本的回合流转。另一个办法是给 Scorer 补一个单元测试:构造一副已知手牌,手动算出期望 deadwood,断言calculateDeadwood的结果完全一致,保证你在改 MeldDetector 时没有伤到算分逻辑。

说起这个,我以前接过一个卡牌项目,就是因为我偷懒不改验证只改逻辑,结果把顺子判断写漏了,导致 AI 明明有三张连续牌却拼命弃牌,那种「看起来正常但行为诡异」的 bug 最费时间。从那以后我每次给规则引擎动刀都会先强制跑一遍对局统计和算分单测,确认数据没有异常波动再谈体验优化。希望这个习惯也能帮你在动手改造时少踩几个坑。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询