简介:一份基于Java的经典杜松子酒接龙(Gin Rummy)纸牌游戏实现,遵循传统二人对战规则,玩家可与电脑AI进行完整对局,适合Java学习者、游戏开发爱好者及需要课程设计参考的高校学生。项目完整覆盖了发牌、出牌、组牌、计分、胜负判定等核心流程,并以面向对象方式抽象牌面、牌组、玩家等实体,帮助读者理解游戏状态管理与逻辑建模;机器人对手的决策逻辑则体现了贪心搜索等基础AI策略。压缩包仅1.44MB,共一百六十三个文件,其中108个gif演示图、15个java源文件和18个class编译文件构成了主要部分,另有xml配置、jar依赖库及工程设置说明,既可直接运行查看交互效果,也可对照源码梳理完整的游戏流程。已有214人学习下载,可作为Java编程、人工智能决策和桌面应用开发的实践范例,读者能从中掌握纸牌规则实现、简单搜索算法应用及代码调试排错思路,为独立开发同类型项目打下扎实基础。
1. 用Java重写Gin Rummy:不只是把牌洗了再扔出去
很多人刚接触Gin Rummy(金拉米)时,觉得这游戏简单:抽一张、扔一张、凑牌型、比点数。真到了用Java动手实现,才发现“凑牌型”这一句背后藏着排列组合和状态机。我也一样,最初写出来的版本连牌都发不均匀,更别提AI的判断逻辑。这套Gin Rummy Card Game工程把完整的对战闭环从零写了出来,包含洗牌、发牌、AI、计分和三层牌型识别。适合正在学Java集合、枚举、递归的开发者,也适合想找一个小而深练手项目的学生。它解决的核心问题是:让你看懂规则如何变成可运行的代码,而不是停留在纸面。
2. 核心规则建模:牌组、手牌、牌型判断怎么落成类
Gin Rummy的规则核心是三件事:牌怎么表示、手牌怎么分组、回合怎么流转。这三件事在代码里对应三类模块:Card/Deck负责牌堆与发牌,HandEvaluator负责计算Deadwood与Gin,GameEngine负责状态推进。把一个游戏项目拆成这三个模块后,后续加AI或换界面都不会互相踩脚。我在做这个工程时,最先动笔的不是界面,也不是AI,而是这几个基础类。因为规则一旦变成代码,所有上层逻辑都会围绕它们转。
2.1 Card与Deck:为什么Card要做成不可变对象
一张牌在Gin Rummy里只需要两个属性:花色和点数。但点数规则有特殊点:A算1,J/Q/K算10,2-10按面值。这个映射最好是固定的,所以我用枚举Rank来固化点数,避免到处写switch。
public final class Card { public enum Suit { CLUBS, DIAMONDS, HEARTS, SPADES } 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 points() { return points; } } private final Suit suit; private final Rank rank; public Card(Suit suit, Rank rank) { this.suit = suit; this.rank = rank; } public Suit getSuit() { return suit; } public Rank getRank() { return rank; } @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof Card)) return false; Card card = (Card) o; return suit == card.suit && rank == card.rank; } @Override public int hashCode() { return 31 * suit.hashCode() + rank.hashCode(); } }把Card设计成final,字段也是final,这样牌一旦创建就不会被修改。实战里我吃过亏:早期用可变对象,AI在模拟“弃牌后结果”时不小心改了原手牌引用,导致后面整局牌的计分都是错的。不可变对象加上每次模拟都new ArrayList<>(hand),才把这个坑填上。
Rank枚举里直接带points字段,ACE的值是1,JACK/QUEEN/KING是10。这样后面算Deadwood时,一行代码就能拿到点数。注意equals/hashCode必须基于suit和rank,否则你在ArrayList.contains()里查牌,明明牌面一样却返回false,这种Bug很难肉眼发现。我见过不少同学用==去比牌,结果牌库里每次都查不到正确对象,最后只能靠打印日志一行行抠出来。
Deck类负责生成52张牌并洗牌:
public class Deck { private final List<Card> cards = new ArrayList<>(); public Deck() { for (Card.Suit suit : Card.Suit.values()) { for (Card.Rank rank : Card.Rank.values()) { cards.add(new Card(suit, rank)); } } } public void shuffle() { Collections.shuffle(cards); } public void shuffle(Random random) { Collections.shuffle(cards, random); } public Card draw() { return cards.remove(cards.size() - 1); } public int size() { return cards.size(); } }Collections.shuffle是Fisher-Yates洗牌算法的标准实现。平时调用不带参与shuffle()就好,测试时用带Random的版本,传入固定种子,比如new Random(42)就能复现同一种牌序。我写AI对战时,为了让两次实验结果可比,都会在测试里固定种子;否则每局牌序都不一样,很难判断是改动算法变强了还是运气变好了。
draw()从列表尾部移除牌,时间复杂度O(1);如果你从头部移除,每次都会移动整个列表。对于一局游戏只抽几十次来说影响不大,但养成习惯总没坏处。还有一个常见问题是初始化时忘了洗牌,直接draw()会按排列顺序发出牌,玩家能算出下一张是什么,这在调试阶段看不出毛病,等到AI对战就会暴露随机性问题。
2.2 HandEvaluator:算清Deadwood是全局的胜负手
Deadwood点数是指手牌里无法组成任何Melds(三张或四张同点数,或者三张以上同花色连续点数)的剩余牌的点数之和。判断能不能敲门、对手扣多少分,全看这个数算得准不准。我把这部分独立成HandEvaluator类,它里面不持有任何状态,所有方法都接收List<Card>并返回int,方便在不同游戏状态间调用。
计算Deadwood的朴素想法是枚举所有可能的牌型组合,但10张牌的复杂组合会非常多。我的做法是递归回溯:从手牌中拿一张牌,先尝试把它放进某个已有的Meld里,或者以它开头新建一个Meld,然后递归处理剩下的牌。
public int computeDeadwood(List<Card> hand) { List<Card> sorted = new ArrayList<>(hand); sorted.sort(Comparator.comparingInt(Card::getRankIndex)); boolean[] used = new boolean[sorted.size()]; return search(sorted, used, 0); } private int search(List<Card> cards, boolean[] used, int start) { int unUsedSum = 0; for (int i = 0; i < cards.size(); i++) { if (!used[i]) unUsedSum += cards.get(i).getRank().points(); } if (unUsedSum == 0) return 0; int best = unUsedSum; for (int i = 0; i < cards.size(); i++) { if (used[i]) continue; // 尝试所有包含第i张牌的meld组合 List<List<Integer>> melds = findMeldsForIndex(cards, used, i); for (List<Integer> meld : melds) { for (int idx : meld) used[idx] = true; best = Math.min(best, search(cards, used, i + 1)); for (int idx : meld) used[idx] = false; } break; // 只处理第一张未用的牌,避免重复计算 } return best; }这段代码逻辑是标准的回溯剪枝。参数说明:
used[]标记哪些牌已经进入牌型,索引和排序后的cards对应。findMeldsForIndex负责返回所有合法的Melds组合,比如数组中找到同点数的其他牌组成Set,或者找到同花色连续牌组成Run。start参数只是为了引导搜索方向,实际上递归里已经通过break保证每次只处理第一张未用牌,避免从不同起点重复搜索同一组合。unUsedSum是当前剩余未分组牌的点数总和。如果已经是0,直接返回,剪枝效果很好。
这里有一个隐蔽的细节:Card::getRankIndex不能直接用Rank.ordinal(),因为ACE后面是TWO,但TWO到KING的递增关系没问题,可ACE通常会当作1而不是最小的0。我在Rank里额外加了rankIndex字段,让ACE排在第1位,这样判断Run时连续关系才是ACE、TWO、THREE……如果直接用ordinal(),ACE和TWO的索引不连续,本来合法的A-2-3就识别不出来了。这就是“排序规则影响核心算法”的一个活生生例子。
另一处容易翻车的是:10的Rank顺序在JACK之前,但JACK的点数和QUEEN/KING一样也是10,所以牌型连续是看点数还是看秩?必须看秩。Run要求的是连续秩,比如9-J-Q就不合法,因为缺少10。我的Rank枚举里把TEN(10)排在NINE之后,getRankIndex()就按这个顺手返回。
2.3 回合流程:抽牌、弃牌与敲门的状态机
回合的核心约束是:每位玩家每轮只能做“抽一张 + 弃一张”。如果抽的是牌堆顶,可以弃任意一张;如果抽的是弃牌堆顶,就必须在手里选一张弃掉,但不能把刚抽上来的那张原样弃回去。这个约束如果不写清楚,AI会写出“抽了弃牌堆又弃回去,等于没换牌”的死循环。
我用一个枚举定义阶段:
public enum TurnPhase { WAITING_FOR_DRAW, // 等待抽牌 WAITING_FOR_DISCARD, // 等待弃牌 TURN_END, // 回合结束 GAME_OVER }GameEngine的核心方法就是推进这个状态机:
public void nextTurn(Player player) { boolean cannotDrawFromDiscard = topDiscard().equals(lastDiscardedBy(player)); // ... }这部分代码在工程里已经跑通了,具体的规则判定和结算都在Controller层。写到这里,读者会发现纸牌游戏的核心不在于界面,而在于“规则不漏”。一个看似简单的“不能把刚抽的牌弃回去”规则,如果不体现在状态约束里,AI就可能在抽到弃牌后立刻弃回去,从而无限循环,整个对战卡死。
3. 对战循环与AI:让电脑知道什么牌该留
Gin Rummy是个两人游戏,所以至少需要一个AI对手。如果没有AI,这个项目只能两个人坐在一起用一个键盘轮流操作,演示效果大打折扣。我做了一个“贪心AI”:每回合枚举所有可能的弃牌选择,计算弃牌后的Deadwood,挑出结果最小的那张。虽然简单,但配合随机性已经能让新手玩家感受到压力。等到你跑熟了,再按第6章的进阶方案把AI调得更难缠。
3.1 游戏状态机:从发牌到结算的完整流转
对战循环可以用一个典型的状态机来描述:
START:洗牌,发牌,每人10张,翻开第一张作为弃牌堆起始牌。PLAYER_TURN/AI_TURN:轮流执行回合。KNOCK:某方敲门,进入结算。GIN:某方形成Gin,直接结算,不予对方补救机会。ROUND_END:算分并判断是否达成目标分(比如设定打到100分,或者固定7局)。
我实现了GameEngine类,内部用TurnPhase枚举保存当前阶段,advance()方法负责在状态间跳转:
public void advance() { switch (phase) { case WAITING_FOR_DRAW -> { // 抽牌后,进入等待弃牌 phase = TurnPhase.WAITING_FOR_DISCARD; } case WAITING_FOR_DISCARD -> { // 弃牌后,检查是否进入结算 if (currentPlayer.wantsToKnock()) { phase = TurnPhase.GAME_OVER; calculateScore(); } else { switchPlayer(); phase = TurnPhase.WAITING_FOR_DRAW; } } default -> throw new IllegalStateException("Unknown phase " + phase); } }这里用Java的switch表达式需要JDK 13+。如果你的项目还在JDK 8上,就用传统switch+break。参数说明:currentPlayer.wantsToKnock()是询问人类玩家的输入接口,AI则调用内部的shouldKnock()方法。我最初把“敲门判定”放在引擎外部,结果出现了“AI刚抽完牌就敲门,还没来得及弃牌”的规则漏洞,所以现在状态机里明确要求:敲门动作只能发生在弃牌之后,这就保证了手牌数永远是10张。
3.2 AI决策:用“弃牌后Deadwood最小”当贪心评分
AI每回合要做两件事:决定抽哪个牌堆,以及弃掉哪张牌。我先实现弃牌决策,因为它影响最大。核心方法:
public Card chooseDiscard(List<Card> hand, Card drawn) { List<Card> candidate = new ArrayList<>(hand); candidate.add(drawn); Card best = null; int bestScore = Integer.MAX_VALUE; for (Card c : candidate) { List<Card> tmp = new ArrayList<>(candidate); tmp.remove(c); int deadwood = evaluator.computeDeadwood(tmp); if (deadwood < bestScore) { bestScore = deadwood; best = c; } } return best; }这段代码把“抽到drawn后的11张手牌”视为候选集合,依次尝试移除每一张,计算剩下10张的Deadwood,选结果最小的那张作为弃牌。为什么不是先判断抽牌?因为弃牌决策本身就是抽牌决策的结果。如果从牌堆顶抽,抽到的牌未知,AI只能根据概率预估;从弃牌堆抽,抽到的牌是已知的,就可以直接算收益。常见做法是先让AI判断弃牌堆顶牌是否“足够好”,够好才抽,否则翻牌堆。
我用一个阈值判断“足够好”:假设弃牌堆顶牌加入手牌后的Deadwood比当前手牌Deadwood低2点及以上,就选它。否则从牌堆抽。这里的2是拍脑袋定的,但实测有不错的效果。如果你想调参,可以做成测试变量,比如把阈值放到AIConfig里,跑1000局对比胜率再来改。
贪心策略有个已知盲区:它只看眼前一步的Deadwood,不考虑“为后续形成Melds留空间”。比如手牌有7-8-9同花,还有单张K。贪心AI可能弃掉K因为它点数大,但这手牌其实已经很接近进攻状态,弃K反而暴露了信息给对手。所以我后面在进阶章节里加了“对手风险模型”,这里先不展开。
3.3 计分规则与Gin判定
计分是游戏的法律条文,差一分整个逻辑就翻车。我用的规则如下(和多数地方规则一致):敲门者如果Deadwood为0,即Gin,得25分奖励;如果敲门时Deadwood非0,则敲门者的Deadwood和对手Deadwood相减,正分给敲门者;如果对手的Deadwood更小(即对手反超),则对手获得“Undercut”奖励,得20分并加上差值。这个细节特别容易被忽略,新手很容易只算“我比你小就赢”而忘记“对手比你小”的情况。
我用一个ScoreCalculator类来集中管理:
public ScoreResult calculate(GinRound round) { int knockerDeadwood = round.getKnocker().getDeadwood(); int opponentDeadwood = round.getOpponent().getDeadwood(); if (knockerDeadwood == 0) { return ScoreResult.gin(25 + opponentDeadwood, -opponentDeadwood); } if (knockerDeadwood < opponentDeadwood) { int diff = opponentDeadwood - knockerDeadwood; return ScoreResult.win(diff, -diff); } if (knockerDeadwood > opponentDeadwood) { int diff = knockerDeadwood - opponentDeadwood; return ScoreResult.undercut(20 + diff, -20 - diff); } // 平局,再加一局 return ScoreResult.again(); }参数说明:round里保存的是手牌快照;这里不直接操作全局状态是为了方便单元测试。ScoreResult是我自定义的不可变结果对象,里面有两个字段:knockerScore和opponentScore。比如Gin时,25 + opponentDeadwood是把对手没入牌的罚分也算给敲门者,这符合传统规则。我认为宁可做一个单独的ScoreCalculator,也别把差分代码散落在GameEngine的各个角落。
如果你要复现这个项目,计分部分建议多写几个单元测试:Gin、普通敲、Undercut、平局重开。这四个分支只要敲错一个,对战结果和排行榜就全是脏数据。
4. 代码结构:从零搭建可扩展的Java工程
很多学Java的人写项目时不做包设计,一个Main.java里堆百来个方法,前期爽,后期想加一个功能就得从全局变量里翻线索。这个Gin Rummy工程从一开始就按职责分模块,后面加AI、换UI都只动对应目录。
4.1 包结构与类职责
我按“模型、控制器、AI、界面”四个包划分,结构如下:
src/main/java/com/ginrummy/ ├── model/ │ ├── Card.java // 单张牌 │ ├── Deck.java // 牌堆 │ ├── HandEvaluator.java // Deadwood计算 │ ├── RoundScore.java // 计分结果 │ └── GameState.java // 游戏状态快照 ├── controller/ │ ├── GameEngine.java // 状态机与回合流转 │ └── ScoreCalculator.java // 计分 ├── ai/ │ ├── AIPlayer.java // AI决策 │ └── AIConfig.java // AI参数配置 └── ui/ ├── ConsoleUI.java // 控制台入口 └── CardPrinter.java // 把牌打印成字符model用于存放纯数据与算法,不依赖任何输入输出;controller负责规则;ai只依赖model和controller的接口;ui作为最外层入口。这样的依赖方向从下到上,不会出现“模型里调界面”这种牵一发动全身的设计。
我见过一个反面案例:有人把洗牌逻辑写在ConsoleUI的main方法里,结果AI测试时也复制了一份洗牌逻辑,两处随机结果不一致,AL的胜率统计完全没有参考意义。把洗牌收进Deck后,所有入口共用同一个实现,才解决这个混乱。
4.2 先做控制台版:为什么省一半调试时间
我一开始也纠结要不要直接上JavaFX,但想了想还是先做控制台版。原因很简单:控制台版可以通过打印语句把每步的中间状态打出来,AI对战能做到每分钟跑几十局。图形界面需要处理事件循环、window关闭等和规则无关的问题,会把调试视线引开。
控制台核心入口只有几十行:
public static void main(String[] args) { GameEngine engine = new GameEngine(); engine.startNewRound(); Scanner scanner = new Scanner(System.in); while (!engine.isFinished()) { System.out.println(CardPrinter.printHand(engine.getHumanHand())); System.out.println("1: 抽牌堆 2: 抽弃牌 3: 查看弃牌顶"); int choice = scanner.nextInt(); // 根据choice调用engine.drawFromStock()或drawFromDiscard() Card discard = engine.chooseDiscard(); System.out.println("你弃掉了 " + discard); } System.out.println(engine.getScoreResult()); }这段代码里省略了输入校验,但在实际工程里要认真处理InputMismatchException。如果用户输入了字母而不是数字,scanner.nextInt()会直接抛异常导致程序退出。我一般用一个循环包住读取操作,非法输入时打印提示并继续。这个坑我在让同学试用时踩了不下三次。
你可能会问:为什么不直接把Scanner封装成HumanPlayer类?我的做法是把玩家动作抽象成PlayerAction接口,ConsoleUI负责从控制台读到动作,这样将来换成图形界面时只需新增一个GUIController,不需要改动引擎代码。这是典型的MVC变体,规模不大但结构清晰。
4.3 用JUnit锁定规则:把血泪教训变成用例
我吃过的最大亏就是改了一步算法后发现旧用例全忘了跑,莫名其妙挂了三个功能。所以这个工程从第一版起就配了JUnit测试,重点覆盖HandEvaluator和GameEngine的状态跳转。比如测试Deadwood:
@Test void aceTwoThreeRunIsZeroDeadwood() { List<Card> hand = new ArrayList<>(); hand.add(new Card(Card.Suit.HEARTS, Card.Rank.ACE)); hand.add(new Card(Card.Suit.HEARTS, Card.Rank.TWO)); hand.add(new Card(Card.Suit.HEARTS, Card.Rank.THREE)); // 计算后为0 HandEvaluator evaluator = new HandEvaluator(); assertEquals(0, evaluator.computeDeadwood(hand)); }这个用例看似简单,却能挡住“Rank索引顺序写错导致A-2-3不连续”的Bug。我在做AI调优时,专门加了一组“同一手牌不同排序输入,Deadwood结果一致”的测试:
@ParameterizedTest @MethodSource("handProvider") void deadwoodIsDeterministicRegardlessOfOrder(List<Card> handA, List<Card> handB) { int a = evaluator.computeDeadwood(handA); int b = evaluator.computeDeadwood(handB); assertEquals(a, b); }因为HandEvaluator内部是排序后计算的,排序稳定性会直接影响结果。JUnit的参数化测试可以一次性跑几十组手牌,速度快到能当回归基准。从那以后,每次改动牌型判断,我第一件事就是跑一遍整个测试类,而不是靠肉眼审查代码。
5. 避坑与排查:Java写牌类游戏的5条血泪经验
这一章记录的是我在开发过程中反复踩过的坑,每一条都对应一次真实的调试经历。你复现时遇到类似问题,可以直接对照排查。
5.1 洗牌不随机,AI战报像复读机
现象:每次开局,AI的思路完全一样,200局里有180局牌序和最早几局一致;换了机器也一样。 原因:Deck.shuffle()里直接调用了Collections.shuffle,但忘了在初始化时调用,或者用了new Random()但你每次启动时都用了相同的系统时间种子(其实Collections.shuffle默认用Random,不会每次一样)。真正的问题是有些代码在new Deck()之后马上就draw(),导致第一张永远是Ace of Spades。 解决:在Deck构造器里强制shuffle(),并留一个测试用的shuffle(Random)入口。我还加了一条单元测试:连续创建10个Deck,洗完后第一张牌不能全部相同。这虽然不能证明随机性,但能挡住“没洗牌”的低级错误。
5.2 集合比较用错“==”,手牌查半天少一张
现象:AI在手牌里找不到弃牌堆顶的牌,每次都不抽;偶尔抽了,又判断说“这张牌不在手牌中”,直接抛异常。 原因:Card类没有重写equals,ArrayList.contains()用了equals,而默认的equals比较引用,不是值。你看着相同的Card其实每次都是new出来的新对象,引用不同当然找不到。 解决:在Card里重写equals和hashCode,并写了cardEqualsByValue测试。从那以后,凡是涉及手牌查找的操作都老老实实走List.contains()或indexOf(),不再自己写循环比较。
5.3 递归枚举Melds陷入指数爆炸
现象:手牌只有10张,但HandEvaluator的computeDeadwood偶尔卡顿超过1秒。调试时发现某些组合下递归调用次数超过百万。 原因:我在search中使用findMeldsForIndex返回了很多重复组合,没有做“去重”和“剪枝”。比如对同点数四张牌,会枚举出好几个包含同一张牌的Set,这些分支算出来的结果一模一样,白白消耗时间。 解决:在递归入口增加一个“只处理第一张未用牌”的break,避免从不同起点重复搜索;同时对findMeldsForIndex做了去重,保证同一种牌型只返回一次。从那以后每个Deadwood计算都稳定在几十毫秒内。
5.4 敲门时Deadwood计算错误,Gin反被对手抓
现象:某次对战中,AI手牌已形成完美Gin,但敲门后被对手Undercut反超。检查日志发现AI的Deadwood算成了1而不是0。 原因:Rank里的点数映射,ACE我定义的是1,但getRankIndex()用了ordinal(),Ace在枚举里排第一,导致2-3-4连续判断时被当成缺少ACE后的牌。另一个原因是,手牌里有两张重复的牌(由于洗牌Bug),导致计算时同一张牌被放进两个Meld里。 解决:给Rank单独增加rankIndex字段,并且每次计算Deadwood前对手牌做“去重并校验”检查,如果发现重复牌直接抛异常。后来我还加了“Gin判定必须硬编码0”的测试用例,不让任何间接依赖影响这个关键输出。
5.5 界面线程卡顿,控制台一切正常
现象:加了JavaFX界面后,点击“抽牌”按钮卡住,随后界面无响应。 原因:把GameEngine的计算直接放在UI线程中,而Deadwood计算里面的递归在极端情况下耗时较长(虽然控制台时看不出),加上UI线程要处理鼠标事件,就卡死了。 解决:把游戏逻辑放到SwingWorker或Task中执行,UI线程只负责显示结果。或者更简单:模拟真实玩家操作时用事件队列,AI计算完成后回调更新界面。这个经验虽然是在扩展图形版时踩到的,但控制台版同样值得注意——不要在AI主线程里嵌套太深的递归。
6. 进阶玩法:让AI更难缠的3个变化
这章写给不想只跑通基础版的人。控制台版能跑只是开始,想让AI更像真人,有三个改动性价比最高。
6.1 记录对手弃牌与顶牌历史
在AIPlayer里加一个Map<Card, Integer> opponentDiscardCounter,每当对手弃牌就+1。AI在决定抽牌时,如果弃牌堆顶的牌在对手历史弃牌中频繁出现,说明对手不想要它,抽它风险更低。这个策略需要小心:如果对手故意弃同色牌来误导你,那统计就会反过来咬你一口。我加了“只统计最近5轮弃牌”的滑动窗口,让模型只反映对手近期偏好。
6.2 动态调整敲门阈值
默认阈值是Deadwood <= 10。但如果你落后很多分,敲门反而会给对手Undercut机会。我改成:当本轮差距超过15分时,阈值降到8;领先时升到12。这种动态参数让AI的侵略性随局势变化。注意阈值直接影响胜率,别把AIConfig写成常量,建议通过启动参数传入,方便批量跑实验。
6.3 复盘日志:让Bug不再玄学
在GameEngine里加一个List<String> eventLog,每次抽牌、弃牌、敲门都记录完整事件,比如[17:03:22] AI抽弃牌堆,弃掉H10。运行100局后,用一个小脚本统计“AI在什么牌型下输得最多”,很容易看出策略弱点。以前我Debug全靠printf,遇到偶发Bug根本复现不了。自从加了日志,很多“这局怎么输了”的疑惑都变成了看得见的证据。
这三个改动加在一起,代码量不大,但会让AI的胜率明显提升。我最后悔的事是最初把AI阈值写死在AIPlayer里,后来为了调参不得不满文件搜索常量。从那以后,每写一个AI决策参数,都会先放进AIConfig配置文件,再跑一轮回归测试。完整源码和运行说明都在下载包里,README里写清了JDK版本和运行命令,照着跑起来,再按这一章改出自己的AI。希望帮到你。
本文还有配套的精品资源,点击获取