Java手写斗地主:从牌型判断到AI出牌策略的完整实现
1. 为什么我要用Java手写一套斗地主斗地主这个游戏看起来规则简单三个人一副牌谁先出完谁赢。但真正动手用Java把它写出来你会发现它几乎把面向对象编程里能踩的坑踩了个遍——牌型判断、出牌合法性校验、AI出牌策略、状态机管理、多线程交互一个都跑不掉。我当初选这个项目练手就是因为它足够“小”小到一个人能扛下来又足够“大”大到能把集合框架、枚举、设计模式、算法逻辑全都串一遍。这篇文章面向的是有一定Java基础、想通过一个完整项目把零散知识串起来的开发者。不管你是刚学完集合和IO、想找个东西练手的学生还是工作几年想回头补一补面向对象设计的老手这套斗地主的源码结构和实现思路都能直接拿去参考。我会把整个项目的设计思路、核心算法、关键代码、踩过的坑全部摊开讲你照着做大概率能跑起来一套属于自己的斗地主。需要先说明的是斗地主的核心难点不在界面而在牌型识别和出牌策略这两块。界面可以用Swing、JavaFX甚至控制台但牌型判断的逻辑是通用的写好了放到哪个UI框架里都能用。所以我的思路是先把游戏内核做扎实再套一层简单的交互界面。2. 整体架构设计与技术选型2.1 为什么选择纯Java SE而不是引入框架很多人一上来就想用Spring Boot或者Netty做网络版斗地主我的建议是先别急。单机版斗地主用纯Java SE就能完整实现不需要任何第三方依赖JDK自带的集合框架、枚举、比较器足够用了。这样做的好处是你能把注意力全部放在游戏逻辑本身而不是被框架的配置和生命周期分散精力。我见过不少初学者项目还没跑通就先搭了一堆框架结果牌型判断写错了都找不到问题出在哪。纯Java SE的好处是调试直观一个main方法就能跑断点打在哪儿都清清楚楚。等你把单机版逻辑吃透了再考虑加网络通信、加数据库排行榜那是水到渠成的事。技术选型上我用了这么几块集合框架ArrayList存手牌HashMap做牌值映射TreeSet做牌型排序枚举花色、牌面、牌型都用枚举避免魔法数字比较器Comparator接口实现手牌排序和牌型大小比较状态机用枚举定义游戏阶段控制出牌轮转Swing做最简交互界面按钮加牌面显示2.2 三层结构拆解实体层、逻辑层、交互层整个项目我拆成了三层这个分层不是照搬教科书而是实际写代码时自然形成的边界。实体层负责定义“牌”和“玩家”这两个核心对象。一张牌有花色和点数两个属性一个玩家有手牌、身份、出牌记录这些属性。这一层只做数据承载不掺任何业务逻辑。逻辑层是整个项目的心脏包含牌堆管理、发牌逻辑、牌型判断、出牌校验、AI决策。这一层的方法都是无状态的纯函数式设计输入手牌和要出的牌输出判断结果方便单元测试。交互层负责把逻辑层的结果展示给用户接收用户操作。Swing的按钮点击、牌面选中、出牌确认都在这一层。这一层尽量薄只做转发和渲染不写业务规则。这样分层的好处是如果哪天你想把Swing换成JavaFX或者加一个网络对战模式只需要替换交互层逻辑层和实体层原封不动。2.3 核心类清单与职责划分我把核心类列一下你心里有个谱类名职责关键方法Card单张牌的实体getSuit, getRank, compareToDeck牌堆管理shuffle, dealPlayer玩家实体addCard, removeCard, getHandCardsCardType牌型枚举定义单张、对子、顺子等CardRule牌型判断核心judgeType, canBeatGameEngine游戏流程控制startGame, playCard, passAIPlayerAI出牌策略decidePlayGameUISwing界面render, bindEvent这个类清单不是一开始就定死的是我写到一半发现职责混乱后重构出来的。比如最初我把牌型判断塞在Player类里后来发现AI和人类玩家都要用就抽出来单独做了CardRule。3. 牌型判断整个项目最硬核的部分3.1 斗地主全部牌型的枚举定义斗地主的牌型比很多人想象的多我整理了一下一共十四种public enum CardType { SINGLE, // 单张 PAIR, // 对子 TRIPLE, // 三张 TRIPLE_WITH_SINGLE, // 三带一 TRIPLE_WITH_PAIR, // 三带二 STRAIGHT, // 顺子五张起 STRAIGHT_PAIR, // 连对三对起 PLANE, // 飞机不带翅膀 PLANE_WITH_SINGLE, // 飞机带单 PLANE_WITH_PAIR, // 飞机带对 BOMB, // 炸弹 ROCKET, // 王炸 FOUR_WITH_TWO, // 四带二 INVALID // 非法牌型 }这里有个坑要提醒顺子不能包含2和王A可以作为顺子的最大牌比如10-J-Q-K-A但2不能接在A后面。这个规则在判断顺子时必须单独处理我当初就是忘了排除2导致AI老是出一些奇怪的牌。3.2 从一组牌反推牌型的算法思路判断牌型的核心思路是先把牌按点数分组统计再看点数分布符合哪种模式。具体步骤是这样的把传入的牌列表按点数排序用HashMapInteger, Integer统计每个点数出现的次数根据点数种类数和每种点数的数量匹配牌型举个例子你拿到[3,3,3,4]这四张牌统计结果是{3:3, 4:1}点数种类是2有一个点数出现3次另一个出现1次这就匹配TRIPLE_WITH_SINGLE。再比如[5,6,7,8,9]统计结果是每个点数都出现1次共5种点数且点数连续这就是STRAIGHT。这个算法的关键在于先统计后匹配而不是逐个去试。我最初写的时候是写了一大堆if-else去判断代码又臭又长后来改成统计法逻辑清晰了不止一个档次。3.3 顺子、连对、飞机的边界处理顺子、连对、飞机这三种牌型最容易出bug因为它们都涉及“连续性”判断。顺子的判断逻辑点数种类数等于牌的张数且最大点数和最小点数之差等于张数减一同时最大点数不能是2或王。这里要注意A在顺子里只能当最大牌不能当1用也就是没有J-Q-K-A-2这种顺子。连对的判断每种点数都恰好出现2次点数种类数至少3种且点数连续同样不能包含2和王。飞机的判断每种点数恰好出现3次点数种类至少2种且连续。飞机带翅膀的情况更复杂需要先找出三张的主体部分再看剩余牌是否符合带牌规则。我踩过的一个坑是飞机带单的时候带的牌不能是三张里的点数也不能是王炸拆开的。这个规则在代码里要单独校验否则会出现333444带55被误判成飞机带对的情况。3.4 牌型大小比较的完整规则牌型比较的规则是同类型才能比大小炸弹和王炸除外。具体来说单张、对子、三张直接比点数三带一、三带二比三张部分的点数顺子、连对、飞机比最小点数或最大点数只要统一就行炸弹比点数炸弹可以压任何非炸弹牌型王炸最大压一切这里有个细节四带二不算炸弹它只是一个普通牌型不能压炸弹。很多人会搞混这一点。代码实现上我写了一个canBeat(CardType type1, int value1, CardType type2, int value2)方法先判断类型关系再比值。这个方法被AI和玩家出牌校验共用保证了规则的一致性。4. 发牌逻辑与手牌管理4.1 一副牌的初始化与洗牌一副斗地主牌是54张13个点数乘以4个花色加上大小王。public Deck() { cards new ArrayList(); for (Suit suit : Suit.values()) { if (suit Suit.JOKER) continue; for (int rank 3; rank 15; rank) { cards.add(new Card(suit, rank)); } } cards.add(new Card(Suit.JOKER, 16)); // 小王 cards.add(new Card(Suit.JOKER, 17)); // 大王 }这里点数我用3到15表示其中15代表216和17代表大小王。这样设计的好处是排序和比较直接按数字来不用额外映射。洗牌用Collections.shuffle(cards)底层是Fisher-Yates算法随机性足够。如果你想要可复现的牌局方便调试可以传入固定的Random种子。4.2 发牌与底牌的分配发牌逻辑是每人17张剩下3张做底牌。public void deal(ListPlayer players) { Collections.shuffle(cards); for (int i 0; i 51; i) { players.get(i % 3).addCard(cards.get(i)); } bottomCards cards.subList(51, 54); }发完牌后每个玩家的手牌要排序方便显示和判断。排序规则是点数从大到小同点数按花色排。底牌的分配是在确定地主后把3张底牌给地主然后重新排序手牌。4.3 手牌排序与显示优化手牌排序看起来简单但实际写的时候有个细节同点数的牌要放在一起这样玩家一眼就能看出自己有几个对子、几个三张。我用的比较器是这样的handCards.sort((c1, c2) - { if (c1.getRank() ! c2.getRank()) { return c2.getRank() - c1.getRank(); // 点数降序 } return c1.getSuit().ordinal() - c2.getSuit().ordinal(); });显示的时候我用Swing的JPanel加JButton每张牌一个按钮点击选中后按钮背景变色。这里要注意选中的牌要按点击顺序记录因为出牌时牌的顺序会影响牌型判断虽然理论上不影响但用户体验上按顺序出牌更自然。5. AI出牌策略的实现5.1 AI决策的基本框架AI出牌策略是这个项目里最有意思的部分。我的AI不是那种能打赢人类的高手但至少能做到“不犯傻”——该压的时候压该让的时候让不会出现手里有炸弹却拆开出单张这种低级错误。AI决策的基本流程是如果自己是主动出牌方上一轮没人出牌或自己最大选择手里最小的合法牌型出如果自己是跟牌方找手里能压过上一手牌的最小牌型如果找不到能压的牌选择过牌特殊情况如果对手只剩一张牌优先出单张压制这个策略的核心是贪心算法——每次出最小的能压过的牌保留大牌和炸弹应对后期。5.2 主动出牌时的最小牌型选择主动出牌时AI要做的第一件事是把手牌拆解成各种可能的牌型组合然后选一个“最不亏”的出法。我的做法是先找单张、对子、三张这些简单牌型按点数从小到大排序优先出最小的。如果手里有顺子或连对也会考虑出但优先级低于单张和对子。这里有个经验AI不要一上来就出顺子因为顺子拆散了很难再组而且容易被对手压。我最初写的AI就是无脑出顺子结果经常被人类玩家用炸弹压住后手里剩一堆散牌。5.3 跟牌时的压制策略跟牌的逻辑比主动出牌复杂因为要判断“值不值得压”。我的策略是如果上一手牌是单张且自己手里有比它大的单张出最小的那个如果上一手牌是对子同理如果上一手牌是顺子或飞机优先用同类型压没有就过炸弹只在两种情况下用对手快出完了或者自己能一波带走这里有个细节AI要记住已经出过的牌。比如场上已经出了三个2那自己手里的2就是最大的单张可以放心出。这个记忆功能我用一个MapInteger, Integer记录每个点数已经出了几张AI决策时参考。5.4 AI的“记牌”与“算牌”进阶基础版AI只记自己手里的牌和场上出的牌进阶版可以加上“算牌”——根据已出的牌推断对手可能有什么牌。比如如果场上已经出了三个K那最后一个K要么在底牌里要么在某个玩家手里。如果某个玩家一直不出单张那他手里很可能没有小单张都是对子或大牌。这个算牌逻辑我实现得比较简单就是统计每个点数剩余的张数AI出牌时优先出剩余张数多的点数因为对手更可能压不住。说实话这个AI打赢人类新手没问题但遇到会算牌的老手还是会被虐。不过作为练手项目这个程度足够了。6. 游戏流程控制与状态管理6.1 游戏阶段的状态机设计斗地主的游戏流程可以拆成几个阶段发牌、叫地主、抢地主、出牌、结算。我用一个枚举来定义这些阶段public enum GamePhase { DEALING, // 发牌中 BIDDING, // 叫地主 PLAYING, // 出牌中 SETTLING, // 结算 GAME_OVER // 游戏结束 }状态机的核心是GameEngine类它持有当前阶段并根据玩家操作切换阶段。比如叫地主阶段玩家点击“叫地主”后状态切换到抢地主如果没人抢直接进入出牌阶段。这个状态机的好处是每个阶段只处理自己的逻辑不会出现“出牌阶段还在处理叫地主”这种混乱。6.2 叫地主与抢地主的逻辑叫地主的规则是从随机一个玩家开始依次询问是否叫地主。如果有人叫了后面的人可以选择抢或不抢。抢地主的人成为新地主如果没人抢原叫地主的人当地主。代码实现上我用一个int currentBidder记录当前叫地主的玩家索引用一个boolean hasBid记录是否有人叫过。每个玩家操作后currentBidder加一取模3直到回到起点或有人抢。这里有个坑如果三个人都不叫要重新发牌。这个逻辑我最初忘了写导致游戏卡死。6.3 出牌轮转与过牌处理出牌阶段的轮转逻辑是当前玩家出牌后轮到下一个玩家。如果下一个玩家选择过牌再下一个。如果连续两个玩家过牌当前玩家获得新一轮的出牌权。我用一个int passCount记录连续过牌次数当passCount 2时重置为0并把出牌权交给最后一个出牌的玩家。这个逻辑看起来简单但实际写的时候容易出bug。比如如果当前玩家出牌后下一个玩家直接出牌没过牌passCount要重置为0。我最初就是忘了重置导致出牌权判断错误。6.4 胜负判定与积分结算胜负判定很简单谁先出完手牌谁赢。地主赢则地主得分农民赢则两个农民各得分。积分规则我简化了底分1分炸弹翻倍春天地主一张牌没出再翻倍。这个规则可以根据需要调整核心是结算逻辑要清晰。结算后游戏状态切换到GAME_OVER显示结果等待玩家点击“再来一局”。7. 常见问题与排查技巧实录7.1 牌型判断错误的排查方法牌型判断出错是最常见的问题排查方法我总结了几条打印中间结果在judgeType方法里打印统计后的点数分布看看是不是和预期一致单元测试覆盖给每种牌型写测试用例特别是边界情况如顺子包含2、飞机带对对比人工判断拿几组牌手动判断再和代码结果对比我踩过的一个坑是333444555这种三连飞机我最初判断成了PLANE但实际上它应该能带翅膀。后来发现是飞机主体识别逻辑写错了把三张的连续判断写成了两张。7.2 AI出牌“卡死”或“乱出”的解决AI卡死通常是因为找不到合法出牌但又没有正确过牌。排查方法是在AI决策方法里加日志打印手牌和上一手牌看看为什么找不到能压的牌。AI乱出通常是因为牌型判断错误导致AI以为某个牌型能压实际上不能。这个还是要回到牌型判断的测试上。还有一个常见问题是AI把炸弹当普通牌出了。这个要在AI策略里加判断炸弹只在特定条件下出。7.3 界面卡顿与线程阻塞的处理Swing界面卡顿通常是因为在事件线程EDT里做了耗时操作。斗地主的AI决策如果写得太复杂可能会阻塞界面。解决办法是把AI决策放到单独的线程里决策完成后通过SwingUtilities.invokeLater更新界面。这样界面不会卡用户体验好很多。7.4 常见问题速查表问题现象可能原因解决方法牌型判断错误统计逻辑有误打印点数分布加单元测试AI不出牌找不到合法牌型检查canBeat逻辑加过牌兜底界面卡死EDT阻塞AI决策放子线程出牌顺序错乱passCount未重置出牌后重置passCount地主底牌未分配叫地主后未处理在进入出牌阶段前分配底牌炸弹不能压类型比较逻辑错误检查canBeat中炸弹的特殊处理8. 项目扩展与个人实操体会这套源码写完之后我做了几次扩展尝试这里分享几个方向。第一个方向是加网络对战。用Java的Socket做TCP通信把GameEngine放在服务端客户端只负责发送操作和接收状态。这个改造工作量不小但核心逻辑不用动因为逻辑层本来就是无状态的。第二个方向是加牌局回放。每次出牌记录到一个列表里游戏结束后可以逐步回放。这个功能对调试很有帮助能看到AI每一步的决策依据。第三个方向是优化AI策略。我试过用简单的蒙特卡洛模拟让AI随机模拟几百次出牌选胜率最高的出法。效果确实比贪心算法好但计算量大需要控制模拟次数。我个人在实际操作中的体会是先把牌型判断写对再写AI最后做界面。这个顺序不能乱。我最初就是先做界面结果牌型判断错了界面上显示得再漂亮也没用。另外单元测试一定要写特别是牌型判断这种纯逻辑的代码测试写好了后面改AI策略时心里有底。最后分享一个小技巧调试的时候可以写一个方法随机生成一手牌然后打印出来手动判断牌型再和代码结果对比。这个方法帮我找出了好几个边界bug。