☰
Java 实现 Gin Rummy 纸牌游戏:规则引擎、回溯算法与 AI 对手
2026/10/24 5:56:57 网站建设 项目流程

简介:Gin-Rummy-Card-Game 是一套基于 Java 的杜松子酒接龙纸牌对战项目,面向希望借助完整小游戏理解面向对象建模、回合制流程与机器人决策的开发者,也可作为课程设计、Java 综合练习的参考案例。压缩包共 163 个文件,大小约 1.44MB,其中 15 个 java 源码承载核心逻辑,18 个 class 文件对应可运行字节码,11 个 xml 与 properties 等文件用于工程和资源配置,另有 108 个 gif 素材服务于牌面与界面展示,并附带 jar、checkstyle、md 等辅助内容,工程结构相对完整。已有 215 人学习下载,对入门者具有一定参考意义。项目围绕牌、牌组、玩家和机器人对手展开,覆盖发牌、出牌、设置与跑动判定、得分计算等环节,读者可据此梳理纸牌类游戏的领域建模思路、AI 出牌策略和界面素材管理方式,也能借助现成源码与工程配置进行调试、复盘和二次开发。

1. 周五晚上三个人凑不齐一桌牌,这个 Java 项目能顶上

朋友临时爽约,两人局的 Gin Rummy(金拉米)纸牌游戏就卡在「第三个人在哪」这件事上。Gin-Rummy-Card-Game 这个 Java 项目干的就是把 52 张牌从洗牌、发牌到摸牌、弃牌、敲牌、结算整条链路用代码跑一遍,命令行窗口里就能开一局,还带一个电脑对手,一个人也能把牌型拆分练熟。它的价值不在于界面多华丽,而在于把 Gin Rummy 那套「顺子 + 同点组合 + 死木最小化」的判定逻辑写成了可读、可测、可改的类:规则引擎单独成包,AI 决策单独成类,UI 只是外壳。适合三类人:Java 入门想找一个不玩具又不复杂的完整项目练手的;做课设或毕设需要一个规则引擎当基座的;想研究「怎么用回溯把 10 张牌的最优分组算出来」的。源码拿到手,换个界面或者加一条规则,比从零写省掉大把时间。

2. 工程结构拆解:包划分、Maven 配置与启动入口

拿到一个 Java 项目,先别急着点运行,先把包结构和构建文件看明白,能省掉后面一半的玄学问题。这个项目的分层思路很朴素:card管牌本身,rule管规则判定,game管流程和玩家,ui管输出。四层之间单向依赖,rule不认识ui,game不认识具体界面,这样后面换控制台还是换 Swing 都不需要动规则代码。

2.1 目录与包结构

源码目录是标准的 Maven 布局,主代码在src/main/java,测试在src/test/java。包名按功能切,不按「controller/service/dao」那套 Web 分层硬套——一个单机牌类游戏没有持久层,硬套反而别扭。

ginrummy/ ├── pom.xml └── src/ ├── main/java/com/demo/ginrummy/ │ ├── Main.java 启动入口,解析参数、装配依赖 │ ├── card/ │ │ ├── Card.java 单张牌,不可变 │ │ ├── Rank.java 点数枚举,带面值与死木分 │ │ ├── Suit.java 花色枚举 │ │ └── Deck.java 牌堆 + 弃牌堆,负责洗牌与抽取 │ ├── rule/ │ │ ├── Meld.java 一组已成型的牌(顺子或同点) │ │ ├── MeldFinder.java 从手牌里枚举所有合法组合 │ │ ├── DeadwoodCalculator.java 回溯求最小死木 │ │ └── Scorer.java 敲牌、Gin、贴牌、undercut 结算 │ ├── game/ │ │ ├── TurnPhase.java 回合阶段枚举 │ │ ├── Player.java 玩家抽象 │ │ ├── HumanPlayer.java 控制台输入实现 │ │ ├── AIPlayer.java 启发式对手 │ │ └── GameEngine.java 状态机主循环 │ └── ui/ │ └── ConsoleView.java 所有 System.out 集中在这里 └── test/java/com/demo/ginrummy/ ├── MeldFinderTest.java └── DeadwoodCalculatorTest.java

几个值得注意的设计点。Card是不可变类,没有 setter,一旦创建就不能改,这样在回溯算法里可以放心地把它塞进集合、当哈希键用,不用担心某处被偷偷改掉导致判定结果对不上。ConsoleView是唯一允许出现System.out的地方,GameEngine只负责决策和状态流转,输出交给视图层回调——这是后面能无痛加图形界面的前提。测试只覆盖rule包,因为流程层的测试成本高、收益低,而规则层一旦算错,整局游戏的分数就全废了。

2.2 构建配置与依赖

依赖极少,这是它作为练手项目的一大优势:不需要配数据库、不需要起容器,mvn package出来一个可执行 jar 就能跑。pom.xml关键部分如下:

<properties> <maven.compiler.source>17</maven.compiler.source> <maven.compiler.target>17</maven.compiler.target> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> </properties> <dependencies> <!-- 只有测试依赖,运行时不引入任何第三方库 --> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter</artifactId> <version>5.10.0</version> <scope>test</scope> </dependency> </dependencies> <build> <plugins> <!-- 打可执行 jar,指定 Main-Class,避免 java -cp 一长串 --> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-jar-plugin</artifactId> <configuration> <archive> <manifest> <mainClass>com.demo.ginrummy.Main</mainClass> </manifest> </archive> </configuration> </plugin> </plugins> </build>

source/target设成 17,是因为代码里用了 switch 表达式和文本块输出牌面。如果你的环境还停在 8 或 11,把这两行改成 11 也能编过,但switch表达式那几处要退回传统写法,改动量不大。project.build.sourceEncoding必须写 UTF-8,否则 Windows 控制台打印花色符号(♠♥♦♣)会变成问号,这个坑后面第 5 章还会提。

提示:maven-jar-plugin只打自己的类,不打依赖。这个项目运行时零第三方依赖,所以直接java -jar就行;哪天你引入了 JSON 库,记得换成maven-shade-plugin,否则运行时会报NoClassDefFoundError。

2.3 启动入口与主循环装配

Main类的职责很克制:解析命令行参数、决定用控制台还是 AI 对战、把Deck、Scorer、GameEngine装配起来,然后交给引擎跑。真正的游戏循环不在Main里,这样写测试的时候可以直接 new 一个GameEngine塞假牌进去,不用启动整个程序。

public final class Main { public static void main(String[] args) { // --ai 指定对手类型,默认人机对战;--seed 固定随机种子便于复现问题 boolean vsAi = true; long seed = System.nanoTime(); for (int i = 0; i < args.length; i++) { switch (args[i]) { case "--ai" -> vsAi = true; case "--pvp" -> vsAi = false; case "--seed" -> seed = Long.parseLong(args[++i]); default -> { System.err.println("未知参数: " + args[i]); return; } } } Deck deck = new Deck(new Random(seed)); // 种子固定 -> 同一局可复现 ConsoleView view = new ConsoleView(); Player human = new HumanPlayer("你", view); Player opponent = vsAi ? new AIPlayer("电脑") : new HumanPlayer("对手", view); GameEngine engine = new GameEngine(deck, List.of(human, opponent), new Scorer(), view, 100); engine.run(); // 目标分 100,先到者赢下整场 } }

这里有两个参数值得单独说。--seed是我认为这个项目里最实用的一个开关:牌类游戏的 bug 往往是「某种特定手牌组合下判定错了」,靠随机跑十局未必复现得了,把种子固定下来,一局打完把种子记进 issue,别人拿到就能原样重放。GameEngine构造函数的最后一个参数是目标总分,Gin Rummy 常见打法是 100 分一局,你也可以改成 250 分打长局。

engine.run()内部是整个游戏的主循环,它做的事按顺序是:洗牌、各发 10 张、翻一张进弃牌堆、进入第一个玩家的摸牌阶段,然后一直循环到有人达到目标分。循环体本身不复杂,复杂的是每个阶段里对「这一步合不合法」的校验,这部分放到第 4 章讲。

3. 规则引擎核心:牌型识别、死木最小化与结算

规则引擎是这个项目的心脏。Gin Rummy 的判定难点不在「是不是顺子」,而在「10 张牌怎么拆,能让剩下的散牌点数之和最小」。这个最小和就是死木(deadwood),敲牌门槛是死木 ≤ 10。算错一分,敲牌决策就全错,所以这块代码值得逐行看。

3.1 牌与牌堆的数据模型

Rank枚举里同时挂了三样东西:面值、显示符号、死木分。把死木分放进枚举而不是散落在计算逻辑里,是为了避免「A 到底算 1 还是 11」这种规则分歧在多个文件里各写一遍。

public enum Rank { ACE(1, "A"), TWO(2, "2"), THREE(3, "3"), FOUR(4, "4"), FIVE(5, "5"), SIX(6, "6"), SEVEN(7, "7"), EIGHT(8, "8"), NINE(9, "9"), TEN(10, "10"), JACK(11, "J"), QUEEN(12, "Q"), KING(13, "K"); private final int value; // 用于顺子连续性判断 private final String label; // 用于控制台输出 Rank(int value, String label) { this.value = value; this.label = label; } public int getValue() { return value; } public String getLabel() { return label; } /** 死木计分:A 记 1,J/Q/K 一律 10,其余按面值 */ public int deadwoodValue() { return Math.min(value, 10); } }

Card本身是不可变的,equals/hashCode按「点数 + 花色」组合实现。这一点很关键:回溯算法里会用索引数组标记牌是否已被占用,但枚举出 meld 之后要用Set<Card>做去重,如果equals只比点数,红桃 K 和黑桃 K 会被当成同一张牌,牌型数量就直接算少了。

public final class Card implements Comparable<Card> { private final Rank rank; private final Suit suit; public Card(Rank rank, Suit suit) { this.rank = rank; this.suit = suit; } public Rank getRank() { return rank; } public Suit getSuit() { return suit; } public int deadwoodValue() { return rank.deadwoodValue(); } @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof Card other)) return false; return rank == other.rank && suit == other.suit; // 花色必须参与比较 } @Override public int hashCode() { return Objects.hash(rank, suit); } @Override public int compareTo(Card o) { // 先按花色再按点数排序,MeldFinder 里靠这个顺序滑动窗口找顺子 int c = Integer.compare(suit.ordinal(), o.getSuit().ordinal()); return c != 0 ? c : Integer.compare(rank.getValue(), o.rank.getValue()); } }

Deck负责两件事:一是初始化 52 张牌并洗牌,二是维护牌堆和弃牌堆两个Deque<Card>。弃牌堆用Deque而不是List,是因为规则只允许取最上面一张,用peekFirst/pollFirst语义更明确,也顺手杜绝了「从弃牌堆中间抽牌」这种非法操作。

3.2 顺子与同点组合的判定

MeldFinder要输出手牌里所有可能的合法组合:3 到 4 张同点数不同花色的 set,以及 3 张以上同花色连续点数的 run。注意 A 只能接在 2 前面(A-2-3),不能接在 K 后面(Q-K-A),这是 Gin Rummy 的通行规则,代码里靠Rank.getValue()的连续性天然实现了,但要在注释里写明白,不然二次开发的人容易以为是漏判。

public final class MeldFinder { /** 返回手牌中所有合法组合,元素是牌在手牌列表中的下标数组 */ public List<int[]> findAll(List<Card> hand) { List<int[]> result = new ArrayList<>(); findSets(hand, result); findRuns(hand, result); return result; } private void findSets(List<Card> hand, List<int[]> out) { Map<Rank, List<Integer>> byRank = new LinkedHashMap<>(); for (int i = 0; i < hand.size(); i++) { byRank.computeIfAbsent(hand.get(i).getRank(), k -> new ArrayList<>()).add(i); } for (List<Integer> idx : byRank.values()) { // 同点数不同花色天然成立(一副牌里单花色唯一),3 张起算 if (idx.size() >= 3) { for (int a = 0; a < idx.size(); a++) for (int b = a + 1; b < idx.size(); b++) for (int c = b + 1; c < idx.size(); c++) { out.add(new int[]{idx.get(a), idx.get(b), idx.get(c)}); // 四张同点数也是一种合法 set if (idx.size() == 4) { int d = 6 - a - b - c; // 0+1+2+3 = 6 out.add(new int[]{idx.get(a), idx.get(b), idx.get(c), idx.get(d)}); } } } } } private void findRuns(List<Card> hand, List<int[]> out) { // 按花色分组后各自排序,用连续段扫描 Map<Suit, List<Integer>> bySuit = new EnumMap<>(Suit.class); for (int i = 0; i < hand.size(); i++) { bySuit.computeIfAbsent(hand.get(i).getSuit(), k -> new ArrayList<>()).add(i); } for (List<Integer> idx : bySuit.values()) { idx.sort(Comparator.comparingInt(i -> hand.get(i).getRank().getValue())); int start = 0; for (int end = 1; end <= idx.size(); end++) { boolean broken = end == idx.size() || hand.get(idx.get(end)).getRank().getValue() != hand.get(idx.get(end - 1)).getRank().getValue() + 1; if (broken) { // 长度 >= 3 的连续段,枚举它的所有子区间(长度 >= 3) emitRunSlices(idx, start, end, out); start = end; } } } } private void emitRunSlices(List<Integer> idx, int from, int to, List<int[]> out) { for (int len = 3; len <= to - from; len++) { for (int s = from; s + len <= to; s++) { int[] meld = new int[len]; for (int k = 0; k < len; k++) meld[k] = idx.get(s + k); out.add(meld); } } } }

这段代码有两个容易翻车的点。第一,findSets里四张同点数同时也要把三张的子集加进去,因为最优解可能只用到三张,第四张拿去凑顺子更划算;第二,emitRunSlices枚举了同一段连续牌的所有子区间,比如手里有 5-6-7-8,会同时产出 5-6-7、6-7-8、5-6-7-8 三种,交给后面的回溯去挑——MeldFinder不做取舍,只负责穷举,取舍是DeadwoodCalculator的事。这个职责分离让两个类都能单独测。

属性说明:findAll返回的是下标数组而不是Card对象本身,因为手牌里可能出现两张完全相同的牌吗?不会——一副牌里花色点数组合唯一。但用下标可以让回溯时的占用标记直接映射到布尔数组,比维护Set<Card>快,也避免了重复牌在集合里被误合并。

3.3 死木最小化的回溯实现

有了全部候选组合,问题就变成:从这些组合里挑一组互不重叠的,让它们覆盖的点数之和最大。等价于剩下的散牌点数最小。手牌最多 10 张(敲牌后可能 11 张),候选组合数量在几十的量级,朴素回溯完全够用,不需要上动态规划。

public final class DeadwoodCalculator { private final MeldFinder finder; public DeadwoodCalculator(MeldFinder finder) { this.finder = finder; } /** 返回手牌的最小死木值 */ public int minDeadwood(List<Card> hand) { int total = hand.stream().mapToInt(Card::deadwoodValue).sum(); List<int[]> melds = finder.findAll(hand); boolean[] used = new boolean[hand.size()]; return total - maxCovered(hand, melds, 0, used, 0); } /** 回溯求不相交组合能覆盖的最大点数 */ private int maxCovered(List<Card> hand, List<int[]> melds, int start, boolean[] used, int covered) { int best = covered; for (int i = start; i < melds.size(); i++) { int[] meld = melds.get(i); if (overlaps(meld, used)) continue; // 与已选组合冲突,跳过 int gain = 0; for (int idx : meld) { used[idx] = true; gain += hand.get(idx).deadwoodValue(); } // 从 i+1 继续,避免同一组合被不同顺序重复枚举 best = Math.max(best, maxCovered(hand, melds, i + 1, used, covered + gain)); for (int idx : meld) used[idx] = false; // 回溯,撤销占用 } return best; } private boolean overlaps(int[] meld, boolean[] used) { for (int idx : meld) if (used[idx]) return true; return false; } }

start参数是这段回溯的精髓。如果每次都从 0 遍历,组合 A 再组合 B 和组合 B 再组合 A 会被当成两条不同路径,虽然结果一样但搜索量翻倍;从i + 1开始保证每个组合只被考虑一次,搜索树被压成组合数而非排列数。手牌 10 张时,这个剪枝让耗时从毫秒级掉到微秒级,虽然对单局游戏没影响,但 AI 每次弃牌要调用 10 次minDeadwood(分别试弃每张牌),累积起来还是能感觉到差别。

参数说明:covered是当前已覆盖点数,作为递归状态往下传,而不是用成员变量,这样这个类就是无状态的、线程安全的,测试里可以并发调用。hand参数每次递归都传,看起来有点浪费,但换来的是方法纯函数化,不会因为共享字段出并发问题。

3.4 敲牌、Gin 与结算

结算规则用一张表说清楚,比读代码快:

场景判定条件得分
普通敲牌敲牌者死木 ≤ 10 且 > 0对手死木 − 自己死木
Gin敲牌者死木 = 0上述差值 + 25 分奖励
Undercut对手贴牌后死木 < 敲牌者死木对手得 25 分 + 双方差值
弃牌被捡弃牌堆顶牌被对手取走不影响分数,但改变摸牌策略

Scorer里的核心方法是settle,它接收敲牌者的手牌和对手的手牌,返回一个ScoreResult,里面记录双方各得多少分、属于哪种结算类型。这里有个规则细节:对手可以把死木牌贴到敲牌者亮出的组合上(lay off),贴完之后再算对手的剩余死木。如果贴完反超,就是 undercut,敲牌者反而要付 25 分惩罚。

public ScoreResult settle(List<Card> knockerHand, List<Card> defenderHand) { int knockerDead = calculator.minDeadwood(knockerHand); // 先算敲牌者亮出的组合,供对手贴牌 List<int[]> knockerMelds = finder.findAll(knockerHand); int defenderDead = calculator.minDeadwoodWithLayoff(defenderHand, knockerHand, knockerMelds); boolean gin = knockerDead == 0; int diff = defenderDead - knockerDead; if (diff <= 0) { // 对手贴牌后死木不高于敲牌者,触发 undercut return new ScoreResult(false, 25 + Math.abs(diff), 0, "UNDERCUT"); } int bonus = gin ? 25 : 0; return new ScoreResult(true, 0, diff + bonus, gin ? "GIN" : "KNOCK"); }

minDeadwoodWithLayoff比普通版本多一层:它允许手牌里的散牌嫁接到敲牌者已亮的组合上。实现方式是把敲牌者的组合拆成「可延伸的序列」和「已满的 set」,然后对手的散牌先尝试往上贴,贴不上的再进普通死木计算。这一段是整个项目里规则最绕的地方,也是最容易在二次开发时改错的地方——很多变体规则(比如不允许贴牌)只要在settle里加个开关就能切换,我在第 6 章会讲怎么加。

4. 对局流程与 AI 对手:状态机驱动的一回合

规则层解决「这手牌值多少分」,流程层解决「现在轮到谁、能做什么」。这块代码如果写成一大坨while + if,加个规则就要动全身,所以项目用状态机把回合切成明确的阶段,每个阶段只允许有限的动作。

4.1 回合状态机

阶段枚举一共六个,流转路径固定:DEAL → DRAW → DISCARD → (KNOCK) → SCORE → GAME_OVER,其中KNOCK是可选的,玩家选择不敲牌就直接回到下一个玩家的DRAW。

public enum TurnPhase { DEAL, // 发牌,一次性 DRAW, // 摸牌:从牌堆顶或弃牌堆顶取一张 DISCARD, // 弃牌:必须弃且只弃一张 KNOCK, // 敲牌:仅在死木 <= 10 时可选 SCORE, // 结算本局 GAME_OVER // 有人达到目标总分 }

GameEngine.run()的主体是一个 while 循环,每次循环处理一个玩家的完整回合。状态机在这里的作用是把「当前阶段允许哪些输入」这件事收敛到一处,而不是散落在各个 if 里。

public void run() { deal(); // 各发 10 张,翻首张弃牌 int current = 0; while (phase != TurnPhase.GAME_OVER) { Player p = players.get(current); phase = TurnPhase.DRAW; Card drawn = p.chooseDraw(deck, view); // 玩家或 AI 决定摸哪张 p.hand().add(drawn); phase = TurnPhase.DISCARD; Card discarded = p.chooseDiscard(deck, view); if (!p.hand().remove(discarded)) { throw new IllegalStateException("弃牌不在手牌中: " + discarded); } deck.pushDiscard(discarded); // 只有死木 <= 10 才有资格进入敲牌阶段 if (deadwood.calculate(p.hand()) <= 10 && p.wantsToKnock()) { phase = TurnPhase.KNOCK; ScoreResult r = scorer.settle(p.hand(), opponentOf(current).hand()); applyScore(current, r); logRoundResult(r); if (hasWinner()) { phase = TurnPhase.GAME_OVER; break; } deal(); // 本局结束,重新发牌 current = 0; continue; } current = (current + 1) % players.size(); } view.showWinner(players); }

一个细节:p.hand().remove(discarded)用的是Card.equals,也就是点数花色都匹配才算数。如果玩家输入的是「弃掉红桃 7」而手里只有方块 7,这里会返回 false 并抛异常,而不是默默删掉一张别的牌。这种「宁可炸也不猜」的写法在规则引擎里是必要的,猜错了后面所有分数都不可信。

applyScore之后会检查是否有人达到目标分,达到就跳出循环;没达到就重新发牌,但保留累计分数。这也是 Gin Rummy 的标准赛制。

4.2 抽牌与弃牌的合法性校验

玩家输入是不可信来源,控制台里敲个x或者99都得接住。合法校验收在HumanPlayer里,不放进GameEngine,因为 AI 不需要这套校验,放进去反而让引擎知道太多输入细节。

@Override public Card chooseDiscard(Deck deck, ConsoleView view) { while (true) { view.prompt("请选择要弃掉的牌(输入序号,从 1 开始):"); String line = scanner.nextLine().trim(); int idx; try { idx = Integer.parseInt(line) - 1; } catch (NumberFormatException e) { view.error("输入不是数字,请重试"); continue; } if (idx < 0 || idx >= hand.size()) { view.error("序号超出范围,手牌共 " + hand.size() + " 张"); continue; } return hand.get(idx); } }

三段式校验的顺序值得留意:先try/catch解析,再判范围,最后才取牌。不要先取牌再判断,否则越界会先抛IndexOutOfBounds,错误信息对用户毫无帮助。chooseDraw同理,但它多一个分支:从弃牌堆取牌时,如果弃牌堆为空(第一回合翻出的那张已经被拿走),要提示并退回牌堆。

Deck这边也要做防护。牌堆抽空的情况在两人局里极少出现,但一旦出现就是NoSuchElementException,所以Deck.drawFromStock()在数量低于阈值时会把弃牌堆(除顶牌外)洗回牌堆。这个逻辑写在Deck内部,调用方无感。

4.3 AI 对手的决策逻辑

AI 没有用搜索树,用的是「弃牌后死木最小」的贪心评估,配合两条启发式规则。思路简单,但打起来不傻,正好适合当二次开发的起点。

@Override public Card chooseDiscard(Deck deck, ConsoleView view) { List<Card> hand = hand(); int base = calculator.minDeadwood(hand); Card bestCard = hand.get(0); int bestScore = Integer.MIN_VALUE; for (Card candidate : hand) { List<Card> rest = new ArrayList<>(hand); rest.remove(candidate); int afterDead = calculator.minDeadwood(rest); // 主目标:弃牌后死木越小越好 int score = -afterDead * 10; // 启发式 1:同等死木下,优先丢掉分值高的牌,减少后续负担 score -= candidate.deadwoodValue(); // 启发式 2:如果这张牌是某些组合的关键张(只在这一个组合里出现), // 丢掉它会让后续搭牌变难,给一点惩罚 if (isSoleBridge(candidate, rest)) score -= 15; if (score > bestScore) { bestScore = score; bestCard = candidate; } } return bestCard; } /** 判断这张牌是否是某个潜在顺子的中间张(只在一侧有邻居) */ private boolean isSoleBridge(Card c, List<Card> rest) { long sameSuitNeighbors = rest.stream() .filter(x -> x.getSuit() == c.getSuit()) .filter(x -> Math.abs(x.getRank().getValue() - c.getRank().getValue()) <= 2) .count(); return sameSuitNeighbors >= 1 && sameSuitNeighbors <= 2; }

score的权重是拍脑袋定的,这也是 AI 唯一的「调参入口」:-afterDead * 10是主项,保证死木优先;-candidate.deadwoodValue()让 AI 倾向于丢 J/Q/K 这种高分牌;-15的桥接惩罚让 AI 不至于把潜在的顺子中间张随手扔了。想让它更激进,把桥接惩罚调到 30;想让它更保守,调到 5。没有绝对正确的值,跑几十局看胜率就能感觉出来。

chooseDraw更简单:比较从牌堆摸一张后的期望死木和直接从弃牌堆取顶牌的即时死木,取顶牌如果能让死木立刻下降就取,否则从牌堆摸。这个策略在残局阶段会有点短视,但作为基础 AI 已经能打出像样的对局了。

5. 本地运行与排查:从编译失败到规则误判

项目跑不起来,八成不是代码有问题,而是环境和参数没对上。这一章按「先跑通、再改错」的顺序来,前面是命令,后面是五条我实际踩过的坑。

5.1 环境准备与构建命令

需要 JDK 17 和 Maven 3.6 以上。不想装 Maven 也可以,现在 IDEA 内置了 Maven wrapper,但命令行构建还是建议装一个。

# 确认 JDK 版本,低于 17 会在 switch 表达式处编译失败 java -version # 在项目根目录执行,第一次会下载依赖 mvn -q clean package # 跑测试,规则层的用例都在这 mvn test # 直接运行可执行 jar java -jar target/ginrummy-1.0-SNAPSHOT.jar --ai --seed 42

-q是静默模式,只输出错误,能少看几十行下载日志。--seed 42固定随机种子,同一局手牌每次运行都一样,调试规则问题时就靠它。

5.2 三种运行方式的选择

方式命令适用场景
可执行 jarjava -jar target/...jar快速试玩,不依赖 IDE
IDEA 直接运行 Main右键Main.java→ Run打断点调 AI 策略
JUnit 用例mvn test -Dtest=MeldFinderTest改规则后回归验证

第三种是最容易被忽略但最有价值的。改完MeldFinder之后不要急着跑游戏,先跑单测,几个简单用例能覆盖 90% 的判定错误。测试用例里最好包含「A-2-3 合法但 Q-K-A 不合法」「四张同点数既要产出 4 张 set 也要产出 3 张 set」这两个容易写错的场景。

5.3 五条踩坑记录

现象一:控制台输出全是问号,牌面变成?。原因:Windows 控制台默认代码页是 GBK,源码文件是 UTF-8,编译时没指定编码,字符串常量就已经乱了。解决:在pom.xml里加project.build.sourceEncoding=UTF-8,同时在 IDEA 的 Settings → File Encodings 里把三处都设成 UTF-8;或者在启动时加-Dfile.encoding=UTF-8。如果只是自己看,也可以把花色符号换成S/H/D/C字母,一劳永逸。

现象二:mvn package报invalid target release: 17。原因:JAVA_HOME指向的还是 JDK 8 或 11,Maven 用的是JAVA_HOME下的 javac,而 IDEA 用的是项目里配的 SDK,两边不一致。解决:命令行echo $JAVA_HOME(Windows 下是echo %JAVA_HOME%)确认版本,改环境变量后重开终端。IDEA 里也要在 Project Structure 里把 Language Level 和 SDK 对齐,否则会在 IDE 里编过、命令行编不过。

现象三:敲牌后分数算出来是负的,或者 undercut 判定反了。原因:settle里比较的是defenderDead - knockerDead,如果对手贴牌后死木更低,差值是负的,代码里用diff <= 0判定 undercut 是对的,但得分公式写成了diff + 25,负差值加上 25 可能还是正数,导致敲牌者反而加分。解决:undercut 的得分要取绝对值再加 25,且记在对手头上,不能记在敲牌者头上。单测里补一条「敲牌者死木 8、对手贴牌后 5」的用例,跑一遍就知道对不对。

现象四:AI 每一手都弃同一张牌,或者疯狂弃牌不敲。原因:chooseDiscard里的score权重全为负,如果某张牌恰好deadwoodValue最小且不触发桥接惩罚,它会在每一轮都被选中;而 AI 不敲牌往往是因为wantsToKnock()里判断死木时用的是「亮牌前」还是「亮牌后」的手牌搞混了。解决:在score里加一个「刚摸到的牌优先弃」的小幅惩罚(比如 −5),避免反复丢同一张;敲牌判断统一用deadwood.calculate(hand) <= 10,不要用minDeadwood之外的变体。

现象五:牌堆抽空后程序崩溃,抛NoSuchElementException。原因:Deck.drawFromStock()直接pop(),没有考虑牌堆见底的情况。两人局里牌堆几乎不会空,但如果改了规则(比如减到 5 张起手),空堆就很容易触发。解决:抽牌前检查stock.isEmpty(),为空则把弃牌堆除顶牌外的牌洗回牌堆;如果弃牌堆也只有一张,说明规则配置有问题,抛一个带说明的异常而不是让pop裸奔。

注意:改规则相关代码之前,先把mvn test跑一遍留个基线。规则层的改动很容易「修一个错三个」,有基线在手,至少知道是自己改坏的还是本来就错。

6. 二次开发:三条改造路线和一个验证习惯

拿到这份源码,最省力的改法是先不动规则层,从外围往里改。我按投入产出比排了三档。

第一档:换界面。ConsoleView是唯一有System.out的地方,把它抽成一个接口(showHand、prompt、error、showWinner四个方法),再写一个 Swing 实现塞给GameEngine就行,引擎一行不用改。这件事一天能做完,改完项目立刻从「黑框框」变成能截图的样子。

第二档:加规则开关。规则变体在 Gin Rummy 里很多,比如不允许贴牌、目标分改成 250、A 允许当 14。做法是在Scorer里加一个RuleConfig参数,把settle里的行为抽成策略:

public record RuleConfig(boolean allowLayoff, int targetScore, boolean aceHigh) { public static RuleConfig standard() { return new RuleConfig(true, 100, false); } }

然后把if (config.allowLayoff())的判断塞进minDeadwoodWithLayoff调用处。改完记得给每种配置补一组单测,别只测默认值。

第三档:换 AI。AIPlayer现在只有贪心加两条启发式,想认真做可以换成「弃牌后死木期望值 + 对手手牌推断」的两层评估,或者干脆上蒙特卡洛:给定当前手牌,随机模拟对手可能的手牌若干次,选出平均收益最高的弃牌。接口不用动,chooseDiscard的签名摆在那,换个实现类就行。

改完之后怎么验证没改坏?我的习惯是固定种子跑回归:准备五个种子,每次改完跑这五局,把最终分数和每局敲牌类型(KNOCK / GIN / UNDERCUT)打出来对比。只要分数模式不变,基本能确定规则没被动过。这比肉眼看代码靠谱得多——规则层的 bug 经常是「大部分牌型都对,只有四张同点数加顺子抢牌的那种边角情况错了」,靠随机对局撞上的概率很低。

说个血泪教训:我有一次顺手把DeadwoodCalculator里start的i + 1改成了i,想着「多搜一点更保险」,结果 AI 每一步都卡住好几秒,手牌 10 张的搜索量直接从几十条路径炸到几万条。这种回溯算法里的边界参数,看着不起眼,改一个字符就是数量级的差别。从那以后我每次动递归的起始下标或者剪枝条件,都强制先跑一遍计时单测,确认耗时在毫秒级再往下走。希望帮到你。

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

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

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

立即咨询