news 2026/10/24 2:58:44

Java 金拉米卡牌游戏:规则引擎、启发式 AI 与 DFS 剪枝拆牌

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Java 金拉米卡牌游戏:规则引擎、启发式 AI 与 DFS 剪枝拆牌

简介:Gin-Rummy-Card-Game 是一份面向Java初学者、课程设计开发者及纸牌游戏爱好者的实战项目包,围绕经典杜松子酒接龙规则,提供玩家与电脑对手进行回合对战的源码、素材与配置。包内共163个文件,以108个gif牌面与界面素材、15个java源文件和18个class编译文件为主体,另有11个xml配置、3个jar依赖以及properties、checkstyle等工程配置,压缩包约1.44MB,目录组织较完整,便于导入IDE阅读和调试。项目将牌、牌组与玩家拆分为独立对象,按发牌、出牌、组合判定与计分推进回合,并涉及机器人出牌策略、事件驱动交互及Swing/JavaFX界面素材,保留的测试类与工程配置也有助于理解规则校验和版本管理。已有215人学习,适合作为课程设计、毕业设计参考或Java游戏开发练手案例,对从规则实现到界面搭建的完整流程有参考价值。

1. 十张手牌里的三件事:先弄清 Gin-Rummy-Card-Game 这副牌在考什么

Gin-Rummy-Card-Game 是一套用 Java 实现的金拉米(Gin Rummy)双人对局程序:每人十张手牌,轮到自己先摸一张、再弃一张,手里凑不成顺子或刻子的散牌(死木)点数够低就能敲牌结算。它体量不大,却把「规则引擎 + 启发式 AI + 回合状态机」这三件最容易写脏的事一次性摆开:拆牌要求最优解,回合顺序不能含糊,AI 还不能蠢到只会丢最大的那张。

适合两类人——想拿一个真实牌局练 Java 对象建模和剪枝算法的开发者,以及手里已经有牌类框架、想对照看看弃牌堆与摸牌堆边界怎么处理的人。源码包能直接编译起来对局,也能把规则层单独抽出来做自对弈压测。按我拆包的习惯,先建模、再算法、再状态机,最后才是踩坑记录和验证手段。

2. 建模与发牌:把 52 张牌压成可计算的整数与不可变对象

牌类游戏的第一道分水岭不在算法,在数据结构。牌到底是String、是枚举、还是int,直接决定了后面拆牌能不能写、日志能不能看、自对弈跑得动不动。

2.1 一张牌身上要挂两个「点数」

新手最常翻的车是把「牌面大小」和「死木计分」当成一个字段。金拉米里 J/Q/K 三张花牌的死木值都是 10,但顺子判连续时它们必须保持 11/12/13,否则 Q-K-A 这种边界牌型就废了。所以Rank上要同时暴露两个数:value()管连续性,deadwood()管计分。

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(11), QUEEN(12), KING(13); private final int value; Rank(int value) { this.value = value; } /** 顺子判连续用,A=1,K=13 */ public int value() { return value; } /** 死木计分用,花牌一律按 10 记 */ public int deadwood() { return Math.min(value, 10); } } public final class Card implements Comparable<Card> { private final Suit suit; private final Rank rank; public Card(Suit suit, Rank rank) { this.suit = suit; this.rank = rank; } public Suit suit() { return suit; } public Rank rank() { return rank; } public int deadwood() { return rank.deadwood(); } /** 52 张牌压成 0..51,日志、哈希、跨进程传输都用它 */ public int id() { return suit.ordinal() * 13 + rank.ordinal(); } @Override public int compareTo(Card o) { int c = Integer.compare(suit.ordinal(), o.suit.ordinal()); return c != 0 ? c : Integer.compare(rank.ordinal(), o.rank().ordinal()); } @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof Card)) return false; Card c = (Card) o; return c.suit == suit && c.rank == rank; } @Override public int hashCode() { return id(); } @Override public String toString() { return rank.name().charAt(0) + suit.name().substring(0, 1); } }

Card做成不可变类是刻意的,不是为了风格好看。拆牌算法(第 3 章)会在几十个搜索分支里同时持有同一批牌的引用,如果牌对象可变,回溯时你改一张牌就会污染所有还在栈上的分支,出来的死木值跟界面上显示的永远对不上,这种 bug 查起来极其难受。把状态变更全部推到「手牌容器」这一层,牌本身只读,回溯天然安全。

id()的用处比想象中大:自对弈跑几千盘时日志里全是C7 D10这种短码不好统计,但换成 0..51 的整数就能直接丢进位图做「哪些牌已经露过面」的判定,比HashSet<Card>快一个量级。hashCode()直接返回id()也省掉了每张牌一次对象哈希计算。

参数说明:value从 1 开始而不是 0,是为了让next = prev + 1这类连续性判断写起来直白;deadwood()用Math.min(value, 10)而不是写三个if,是因为这条规则在所有牌型里恒定,写成表达式不会漏分支。如果你要支持 A 当 11 的变体(A-2-3 与 Q-K-A 都成立),别改value,在顺子枚举那一层单独补一条「A 高」的候选通道,否则整套死木计算跟着烂掉。

2.2 手牌容器与规则参数:三选一的取舍

手牌在内存里怎么放,是个值得停一下的问题。常见三种表示:

表示方式优点代价适用位置
List<Card>顺序稳定、易打印、易做下标位掩码判重靠equals,单次 O(n)拆牌算法输入、UI 展示
EnumMap<Suit, EnumSet<Rank>>按花色聚合,找顺子快丢顺序,同一张牌无法按位置回溯顺子预筛
long52 位位图判存在 O(1),求交集一条与运算不可读,调试全靠反解「已露面牌」快照、胜负统计

我的做法是:**手牌用List<Card>当唯一事实来源,位图只当派生的缓存。**拆牌算法需要「第 i 张牌是否已被占用」这种按位置的操作,位图在这儿反而绕——你没法用 52 位位图区分同点数的两张牌分别在第几个位置。

然后是发牌。洗牌这段代码短,但坑集中:

public final class Deck { private final Deque<Card> stock = new ArrayDeque<>(); private final List<Card> discardPile = new ArrayList<>(); public void reset() { stock.clear(); discardPile.clear(); List<Card> all = new ArrayList<>(52); for (Suit s : Suit.values()) for (Rank r : Rank.values()) all.add(new Card(s, r)); // Fisher-Yates:从后往前,每个位置与 [0, i] 里随机一个位置交换 ThreadLocalRandom rnd = ThreadLocalRandom.current(); for (int i = all.size() - 1; i > 0; i--) { int j = rnd.nextInt(i + 1); Collections.swap(all, i, j); } stock.addAll(all); } public Card drawFromStock() { return stock.pollFirst(); } public Card peekDiscard() { return discardPile.isEmpty() ? null : discardPile.get(discardPile.size() - 1); } public Card takeDiscard() { return discardPile.remove(discardPile.size() - 1); } public void discard(Card c) { discardPile.add(c); } public int stockSize() { return stock.size(); } }

逻辑说明:stock用ArrayDeque而不是Stack,因为后者每个方法都带同步,自对弈几万次摸牌下来是实打实的开销。弃牌堆用ArrayList是因为只有「尾部增删 + 看最后一张」两种操作,且要按顺序展示最近几张牌,Deque的语义在这儿不划算。

参数说明:rnd.nextInt(i + 1)里的上界必须是i + 1而不是i,写成nextInt(i)会让第 i 张永远换不出去,牌序出现可测偏差——单看一局看不出来,跑一千盘统计「黑桃出现位置分布」就能看出鼓包。另外千万不要在reset()里new Random():同一毫秒内连开两盘,种子一样,发牌一模一样,人机对战时会以为程序卡住了。

规则参数我习惯全部抽到一个不可变配置里,别散落在GameEngine的字段上:

public record RulesConfig(int handSize, int knockThreshold, int ginBonus, int undercutBonus, int targetScore) { public static RulesConfig standard() { return new RulesConfig(10, 10, 25, 25, 100); } }
参数常见取值改了会怎样
handSize10改大等于换游戏;拆牌 DFS 输入从 11 涨到 13,节点数上去一截
knockThreshold10调高会提前结束回合,AI 变得激进,自对弈的 Gin 比例明显下降
ginBonus25只影响结算,不参与策略搜索,随便调
undercutBonus25同上,但调太大会让 AI 的敲牌决策显得保守
targetScore100只决定一局打几盘,不影响单盘逻辑

提示:RulesConfig用record需要 Java 16 以上。编译目标如果是 8 或 11,改成普通final类加 getter,别为了这一个类把整包工具链抬上去。

3. 死木最小化:11 张手牌的拆牌 DFS 与两条剪枝

摸完牌手里 11 张,要在所有「顺子 + 刻子」的划分方案里挑一个让剩余散牌点数和最小的。这是整个项目里唯一有算法含量的地方,也是最容易写成「能跑但结果不对」的地方。

3.1 贪心一定翻车,先看一个具体例子

很多人第一反应是贪心:先把能凑的最长顺子拿走,再找刻子。拿这手牌试试 ——♣9 ♣10 ♣J ♣Q ♥J ♦J。

  • 先取刻子 J:♣J ♥J ♦J成型,剩♣9 ♣10 ♣Q,死木 9 + 10 + 10 =29。
  • 先取顺子:♣9 ♣10 ♣J ♣Q成型,剩♥J ♦J,死木 10 + 10 =20。

同一个贪心框架,只是「先找顺子还是先找刻子」的顺序不同,结果差 9 点。在敲牌阈值 10 的规则下,这个差值直接决定这盘能不能敲。所以拆牌只能做完全搜索,贪心最多当上界剪枝用。

3.2 候选牌型用位掩码枚举

先不管怎么组合,把「所有可能成立的顺应和刻子」列出来。手牌最多 11 张,一个int的 32 位完全够用,第 i 位表示手牌第 i 张被这个组合占用。

public final class MeldSolver { /** 一次拆牌的结果:成型的 meld、剩下的死木、死木点数和 */ public record Solution(List<List<Card>> melds, List<Card> deadwood, int deadwoodValue) {} private final List<Card> hand; private final List<Integer> meldMasks = new ArrayList<>(); private final Deque<Integer> chosen = new ArrayDeque<>(); private List<Integer> bestChoice = new ArrayList<>(); private int bestDeadwood = Integer.MAX_VALUE; public MeldSolver(List<Card> hand) { this.hand = List.copyOf(hand); buildMelds(); } /** 枚举所有长度 >= 3 的顺子与刻子,各自压成一张位掩码 */ private void buildMelds() { int n = hand.size(); // 顺子:同花色按点数升序,从每个起点向后延伸,点数一断就停 Map<Suit, List<Integer>> bySuit = new EnumMap<>(Suit.class); for (int i = 0; i < n; i++) bySuit.computeIfAbsent(hand.get(i).suit(), k -> new ArrayList<>()).add(i); for (List<Integer> idx : bySuit.values()) { idx.sort(Comparator.comparingInt(i -> hand.get(i).rank().value())); for (int a = 0; a < idx.size(); a++) { int mask = 1 << idx.get(a); int prev = hand.get(idx.get(a)).rank().value(); for (int b = a + 1; b < idx.size(); b++) { int v = hand.get(idx.get(b)).rank().value(); if (v != prev + 1) break; mask |= 1 << idx.get(b); prev = v; if (Integer.bitCount(mask) >= 3) meldMasks.add(mask); } } } // 刻子:同点数 3 张或 4 张,枚举全部子集(4 张时既可当 3 张刻子也可当 4 张) Map<Rank, List<Integer>> byRank = new EnumMap<>(Rank.class); for (int i = 0; i < n; i++) byRank.computeIfAbsent(hand.get(i).rank(), k -> new ArrayList<>()).add(i); for (List<Integer> idx : byRank.values()) { int m = idx.size(); for (int sub = 0; sub < (1 << m); sub++) { if (Integer.bitCount(sub) < 3) continue; int mask = 0; for (int k = 0; k < m; k++) if ((sub & (1 << k)) != 0) mask |= 1 << idx.get(k); meldMasks.add(mask); } } } }

逻辑说明:顺子那段的内层循环一旦遇到点数不连续就break,这依赖外层已经按点数排好序;如果忘了idx.sort(...),break会让后面本来能连上的牌被整段丢掉,症状是「明明有 9-10-J-Q 却只识别出 9-10-J」。刻子那段用子集枚举而不是硬编码「3 张」和「4 张」两种情况,是因为一旦手牌里出现四张同点数(比如摸到第四张 7),3 张刻子和 4 张刻子都是合法划分,硬编码很容易漏掉其中一种。

参数说明:1 << idx.get(i)能安全工作的前提是手牌规模小于 31。金拉米摸完牌 11 张,安全;但如果你打算把这段复用到别的牌类游戏(比如二十一点分牌后可能有更多张),换成long并同步改Integer.bitCount为Long.bitCount,否则第 31 位之后全是负数,bitCount出来的数是错的——这个 bug 不会抛异常,只会让 AI 越打越差。

3.3 只对「最小未分配牌」分支的 DFS

有了候选集合,接下来的搜索有个关键设计:不枚举「选哪些 meld」,而是每次找第一个还没被分配的手牌,只对它做两个决策 —— 当死木,或者进某个包含它的 meld。这一招把重复组合从搜索树里彻底删掉了。

public Solution solve() { dfs(0, 0); List<List<Card>> melds = new ArrayList<>(); int used = 0; for (int mask : bestChoice) { List<Card> group = new ArrayList<>(); for (int i = 0; i < hand.size(); i++) if ((mask & (1 << i)) != 0) group.add(hand.get(i)); melds.add(group); used |= mask; } List<Card> deadwood = new ArrayList<>(); for (int i = 0; i < hand.size(); i++) if ((used & (1 << i)) == 0) deadwood.add(hand.get(i)); return new Solution(melds, deadwood, bestDeadwood); } private void dfs(int used, int deadwoodValue) { // 剪枝:当前已累计的死木不比已知最优好,往下只会更差 if (deadwoodValue >= bestDeadwood) return; // 找第一个未分配的手牌下标,只对它分支 int i = 0; while (i < hand.size() && (used & (1 << i)) != 0) i++; if (i == hand.size()) { // 全部牌都分配完了,记录一个可行解 bestDeadwood = deadwoodValue; bestChoice = new ArrayList<>(chosen); return; } // 分支一:这张留作死木 dfs(used | (1 << i), deadwoodValue + hand.get(i).deadwood()); // 分支二:这张进某个包含它的 meld for (int mask : meldMasks) { if ((mask & used) != 0) continue; // 有牌已被占用 if ((mask & (1 << i)) == 0) continue; // 不包含当前这张 chosen.push(mask); dfs(used | mask, deadwoodValue); chosen.pop(); } } }

逻辑说明:used是已分配掩码,i是「最小未分配」,两个分支都把第 i 张处理掉了,所以搜索树严格向下、不会兜圈。如果不按最小下标分支,改成「枚举所有 meld 子集」,同一组划分会被不同顺序访问到很多次,11 张牌的搜索量会从几百个节点膨胀到几万,人机对战时每次 AI 弃牌都要算一遍,界面明显卡顿。

参数说明:if (deadwoodValue >= bestDeadwood) return;里的>=而不是>是有意的 —— 我们只需要一个最优解,不需要把所有同分方案都找出来。如果你要做「AI 提示,展示所有最优拆法」,这里必须改成>并额外维护一个解的列表,否则永远只会看到第一种。另外这个剪枝在根节点就在生效,把bestDeadwood初始化为Integer.MAX_VALUE是必须的,别写成 0,否则一开始就被剪没了。

手牌 11 张时,候选 meld 通常在十几个到二十几个,搜索节点量级在几百到几千,单次求解是微秒级,完全撑得住每回合现算。要是你把这个逻辑搬到 13 张或更多的变体上,先加个「按

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/24 2:58:21

genesis97.rar安全解包与归档入库:判型、校验和与隔离对账

简介&#xff1a;Genesis 97 压缩包面向需要在 Windows 10 64 位环境下使用 PCB 线路板处理工具的工程人员&#xff0c;核心为 Genesis 9.07b2 程序及配套运行文件&#xff0c;强调简化安装流程&#xff0c;减少安装中断、安装后内部报错等常见问题。包内共 952 个文件&#xf…

作者头像 李华
网站建设 2026/10/24 2:57:10

opencode开源终端AI编程助手实测:多模型接入与TUI交互

AI 编程助手这两年被卷得不成样子&#xff0c;光是终端里能跑的就有不少选择&#xff1a;某商业模型的官方命令行工具、老牌的开源 CLI 编程 agent&#xff0c;再加上各家 IDE 的插件&#xff0c;说实话选项多到让人犯困。但这里有个问题——大部分工具都有明显的绑定倾向&…

作者头像 李华
网站建设 2026/10/24 2:55:32

Python数据结构实战:列表、元组、字典与集合的性能选型指南

作为一个写了多年 Python、又带过不少新人项目的开发者&#xff0c;我越来越觉得一件事很明确&#xff1a;数据结构不是语法知识的考卷&#xff0c;而是你代码运行时所有决策的地基。很多新手能把list.append()、dict["key"]背得滚瓜烂熟&#xff0c;但真正面对一个“…

作者头像 李华
网站建设 2026/10/24 2:55:11

AI Coding评测失真?三层实测法拆穿编码工具虚火

这两年“AI Coding评测”这个词几乎逢会必谈&#xff0c;但“到底谁在裸泳”这个问题&#xff0c;反而越谈越糊。宣传海报上一个比一个高的通过率&#xff0c;一到自己手上就变个样。这篇文不是又一份榜单&#xff0c;而是一套我自己反复用过的、专门用来拆穿AI编码工具虚火的评…

作者头像 李华
网站建设 2026/10/24 2:54:29

Jetson TX2刷机实战:JetPack 4.6.6升级与初始化指南

最近刚帮团队处理一台闲置的Jetson TX2开发套件&#xff0c;系统还停在很老的JetPack 3.3上&#xff0c;跑新一点的深度学习模型时算子兼容性各种糟心。查了一下官方支持列表&#xff0c;TX2能用的最新JetPack就是4.6.6&#xff0c;基于L4T R32.7.6&#xff0c;Ubuntu 18.04&am…

作者头像 李华
网站建设 2026/10/24 2:53:56

AI行业岗位全景:从算法高薪岗到零基础服务岗怎么选

过去两年&#xff0c;我大概被问过上百遍同一个问题&#xff1a;“现在转AI还来得及吗&#xff1f;”每次听到这种问题&#xff0c;我都会先反问一句&#xff1a;你说的“AI岗”&#xff0c;到底是什么岗&#xff1f;结果大多数人能想到的&#xff0c;无非就是算法工程师、大模…

作者头像 李华