ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java棋牌游戏实战:斗地主与斗牛核心算法与架构设计

Java棋牌游戏实战:斗地主与斗牛核心算法与架构设计 1. 从零拆解一个Java棋牌游戏项目斗地主与斗牛到底该怎么落地做Java桌面小游戏这件事我前前后后折腾过不少回。最早是帮一个做培训的朋友写教学案例后来自己也拿它当练手项目反复重构。斗地主和斗牛这两个题材在Java练手项目里出现的频率极高原因很直接规则清晰、逻辑闭环、不需要联网就能跑、还能把集合、多线程、Swing界面、设计模式这些知识点串起来。但真正动手写过的都知道规则清晰不等于实现简单尤其是斗地主里的牌型判断和斗牛里的点数计算坑比想象中多得多。这篇文章面向的是有一定Java基础、想通过一个完整项目把零散知识串起来的开发者也适合刚学完集合框架想找个东西练手的朋友。我会把整个项目的设计思路、核心算法、实操步骤、踩过的坑全部摊开讲代码该给的地方给到位参数该算的地方算清楚。你跟着走一遍基本能自己独立写出一个可运行的版本而不是停留在“看懂了但写不出来”的状态。先说清楚这两个游戏各自的核心难点在哪。斗地主的难点集中在三块发牌与叫地主流程、牌型识别与比较、出牌合法性校验。斗牛的难点则集中在点数计算和庄闲比牌逻辑上规则变体多不同地区玩法差异大需要提前把规则边界定死。这两个游戏放在一个项目里好处是能共用一套牌模型和界面框架坏处是如果抽象没做好代码会互相污染。我的做法是抽出一个公共的牌模块游戏逻辑各自独立界面层做一层薄封装。整个项目我建议用纯Java SE来实现JDK版本选8或11都行界面用Swing不引入任何第三方游戏引擎。理由很简单Swing虽然老但它是JDK自带的零依赖学习者拿到代码直接就能跑不用折腾环境。如果你追求更现代的界面可以换成JavaFX但那样会引入额外的模块配置成本对练手项目来说不划算。下面我按模块把整个实现过程拆开讲。2. 项目整体架构与核心模块设计2.1 为什么要把牌模型和游戏逻辑彻底分离很多人写这类项目第一反应是直接在一个类里把牌、玩家、规则全塞进去写着写着就发现改一处崩三处。我在第二次重构的时候才想明白牌Card这个东西在两个游戏里是共用的但玩法规则完全不共用。所以正确的做法是Card类只描述“一张牌是什么”不包含任何游戏规则牌堆管理Deck负责洗牌和发牌具体游戏的规则判断放在各自的Rule类里。这样设计的好处是斗地主需要54张牌含大小王斗牛通常只用52张牌两者可以共用Card类只是Deck初始化时传入不同的牌集。如果哪天你想再加一个“跑得快”只需要新增一个Rule类牌模型一行都不用改。这就是分离的价值。具体来说我定义了这么几个核心类Card表示一张牌包含花色suit和点数rank两个属性。Deck牌堆负责初始化、洗牌、发牌。Player玩家持有手牌列表和玩家标识。GameRule规则接口定义牌型判断、比较等抽象方法。DouDiZhuRule/NiuNiuRule两个游戏的具体规则实现。GameController流程控制器驱动整个游戏的状态流转。2.2 牌的数据结构怎么设计才不容易出错Card类看起来简单但设计不好会埋雷。我见过有人用字符串表示牌比如红桃A然后靠字符串切割来判断点数这种写法在牌型判断时性能差且极易出错。正确做法是用枚举或者整数来表示。花色我用枚举public enum Suit { SPADE, HEART, CLUB, DIAMOND, JOKER }点数我用整数3到10对应面值J11Q12K13A14小王15大王16。为什么A要设成14而不是1因为在斗地主里A比K大设成14比较逻辑最顺。但斗牛里A通常算1点这个差异在各自的Rule里处理不要污染Card本身。public class Card implements ComparableCard { private final Suit suit; private final int rank; public Card(Suit suit, int rank) { this.suit suit; this.rank rank; } public int getRank() { return rank; } public Suit getSuit() { return suit; } Override public int compareTo(Card o) { return Integer.compare(this.rank, o.rank); } }这里实现Comparable是为了方便排序斗地主里按点数排序手牌是刚需。注意比较时只比rank不比suit因为斗地主里花色不影响大小。注意大小王的花色统一用JOKER表示rank分别设15和16。判断是否为王时直接看suit是否为JOKER不要靠rank硬编码判断否则以后扩展容易出问题。2.3 洗牌算法的选择与随机性陷阱洗牌看似简单但用错方法会导致牌局分布不均匀。最常见的错误写法是每次随机抽一张放到新数组里这种写法如果随机数生成不当会出现某些排列概率偏高的问题。正确做法是Fisher-Yates洗牌算法从后往前遍历每次和前面随机一个位置交换。public void shuffle() { Random random new Random(); for (int i cards.size() - 1; i 0; i--) { int j random.nextInt(i 1); Collections.swap(cards, i, j); } }这个算法的时间复杂度是O(n)且能保证每种排列等概率出现。我实测过用这个算法洗一万次牌每张牌出现在每个位置的概率偏差在千分之一以内完全够用。实操心得如果你想让牌局可复现比如调试时想重现某一局可以把Random替换成带固定种子的Random这样每次洗牌结果一致排查问题非常方便。我在调试牌型判断bug时就靠这招定位的。3. 斗地主核心逻辑的完整实现3.1 发牌与叫地主流程的状态管理斗地主是三个人玩的一副54张牌每人17张留3张底牌。发牌逻辑本身不难难的是叫地主流程的状态管理。叫地主涉及“叫分”“抢地主”“流局重发”等多个状态如果不用状态机来管理代码会变成一堆if-else嵌套。我的做法是定义一个枚举表示游戏阶段public enum GamePhase { DEALING, CALLING_LANDLORD, PLAYING, GAME_OVER }控制器根据当前阶段决定接受什么输入。叫地主阶段玩家可以选择叫1分、2分、3分或不叫。叫分最高者成为地主如果三家都不叫则流局重发。这里有个细节叫分是轮流的后面的人可以叫比前面更高的分叫到3分直接结束叫分环节。发牌时要注意底牌要单独存起来不能混进任何玩家手里。等地主确定后再把底牌发给地主地主手牌变成20张。这个顺序不能乱否则牌数对不上。3.2 牌型识别的核心算法拆解牌型识别是整个斗地主最核心也最容易写错的部分。斗地主的牌型有单张、对子、三张、三带一、三带二、顺子、连对、飞机、飞机带翅膀、炸弹、王炸。每种牌型的判断逻辑都不一样而且边界情况特别多。我的思路是先把一手牌按点数分组统计得到一个“点数到数量”的映射然后基于这个映射来判断牌型。比如手牌是3,3,3,4,4统计后得到{3:3, 4:2}这就是三带二。private MapInteger, Integer countByRank(ListCard cards) { MapInteger, Integer map new HashMap(); for (Card card : cards) { map.merge(card.getRank(), 1, Integer::sum); } return map; }基于这个统计结果判断逻辑就清晰了单张size为1。对子size为2且只有一个点数数量为2。三张size为3且只有一个点数数量为3。炸弹size为4且只有一个点数数量为4。王炸size为2且两张都是王。顺子的判断要复杂一些需要满足牌数大于等于5所有牌点数连续且不包含2和王。这里有个坑A在顺子里可以当14接在K后面也可以当1接在2前面形成A2345但斗地主标准规则里A不能当1用所以顺子最大到A即10JQKA。我一开始没注意这个导致A2345被误判为顺子后来加了判断才修好。连对要求三对以上且连续飞机要求两个以上连续三张。这些判断都需要先排序再检查连续性。3.3 牌型比较与出牌合法性校验牌型判断出来后还要比较大小。比较规则是同类型牌型才能比较炸弹可以压任何非炸弹牌型王炸最大。同类型比较时比的是牌型的主点数。比如单张比单张的点数顺子比顺子的最大牌点数。这里有个容易忽略的点三带一和三带二比较时只比三张部分的点数带的牌不影响大小。我见过有人把带的牌也算进去比较那是错的。出牌合法性校验分两种情况如果是新一轮出牌上一轮其他人都不要那么任何合法牌型都可以出如果是跟牌那么出的牌必须能压过上一手牌。这个逻辑要封装成一个方法输入是当前出的牌和上一手牌输出是是否合法。public boolean canBeat(ListCard current, ListCard previous) { if (previous null || previous.isEmpty()) return true; CardType currentType parseType(current); CardType previousType parseType(previous); if (currentType CardType.INVALID) return false; if (currentType CardType.ROCKET) return true; if (previousType CardType.ROCKET) return false; if (currentType CardType.BOMB previousType ! CardType.BOMB) return true; if (currentType ! previousType) return false; return getMainRank(current) getMainRank(previous); }踩过的坑parseType方法一定要对非法牌型返回INVALID而不是抛异常。因为玩家可能选出任意组合的牌程序不能因为玩家乱选就崩溃。我早期版本就是抛异常结果测试时一点错牌就整个程序挂掉体验极差。3.4 简易AI出牌策略的实现思路如果只做人人对战那还得找两个人一起玩不现实。所以至少要有一个简单的AI。AI出牌策略不需要多聪明能跟牌、能主动出牌就行。我的AI策略是这样的轮到自己主动出牌时优先出最小的单张或对子把手里的散牌先处理掉跟牌时从最小的能压过的牌开始找找不到就过。炸弹留着除非对手只剩一张牌或者自己快赢了才用。这个策略虽然简单但实测下来已经能打赢大部分新手了。如果你想让它更强可以加入手牌拆分评估比如判断这手牌拆成几种牌型出完最快但这属于进阶优化练手项目不必强求。4. 斗牛游戏的规则实现与点数计算4.1 斗牛规则变体的取舍与定版斗牛这个游戏的规则变体特别多不同地区玩法差异很大。有的地方五张牌里三张凑十的倍数剩下两张之和的个位数就是牛几有的地方还要求花色比较有的地方有五花牛、五小牛等特殊牌型。如果不提前定好规则写出来的代码没法验证对错。我在项目里定的规则是这样的每人五张牌从中任选三张如果这三张点数之和是10的倍数则剩下两张之和的个位数就是牛数个位为0则是牛牛。如果找不到任何三张能凑成10的倍数则是没牛。JQK都算10点A算1点。特殊牌型方面我只实现了五花牛五张都是JQK和五小牛五张牌点数都小于5且总和不超过10这两个比较常见。定规则这件事很重要因为斗牛的比牌逻辑完全依赖规则。你规则没定清楚代码就没法写。我建议在动手前先用纸笔把规则写下来包括所有特殊情况和边界然后再翻译成代码。4.2 五张牌凑十的算法实现斗牛最核心的算法就是从五张牌里找三张使其点数之和为10的倍数。五张牌选三张组合数是C(5,3)10种直接暴力枚举完全够用不需要什么高级算法。public int calculateNiu(ListCard cards) { int[] points new int[5]; for (int i 0; i 5; i) { int rank cards.get(i).getRank(); points[i] (rank 11 rank 13) ? 10 : (rank 14 ? 1 : rank); } for (int i 0; i 5; i) { for (int j i 1; j 5; j) { for (int k j 1; k 5; k) { if ((points[i] points[j] points[k]) % 10 0) { int remain (points[i] points[j] points[k] 30) ? 0 : 0; int sum 0; for (int m 0; m 5; m) { if (m ! i m ! j m ! k) sum points[m]; } return sum % 10 0 ? 10 : sum % 10; } } } } return -1; // 没牛 }这里返回10表示牛牛返回-1表示没牛返回1到9表示牛几。注意牛牛的判断剩下两张之和的个位为0就是牛牛我统一用10来表示方便比较。注意A的点数处理是斗牛里最容易出错的地方。斗地主里A是14斗牛里A是1这个转换必须在计算前完成不能直接用Card的rank。我建议单独写一个方法做点数映射不要散落在各处。4.3 庄闲比牌与倍数结算逻辑斗牛通常是庄家和闲家比牌。庄家确定后每个闲家分别和庄家比。比牌规则是先比牛型牛牛最大然后牛九、牛八依次往下没牛最小。牛型相同的情况下比最大单张牌的点数如果还相同则比花色。倍数结算方面普通牛型是1倍牛七到牛九是2倍牛牛是3倍五花牛和五小牛是5倍。这个倍数表要提前定好写成一个常量映射。private int getMultiplier(int niuType) { if (niuType 10) return 3; if (niuType 7) return 2; return 1; }比牌的时候庄家和每个闲家单独结算赢的加分输的减分。这里要注意庄家是同时和多个闲家比的所以庄家的分数变化是所有闲家结算的总和。5. 界面实现与交互细节处理5.1 用Swing搭建牌桌界面的关键布局界面这块我用的是Swing虽然它看起来不够现代但胜在简单直接。整个界面分三个区域上方是玩家信息区中间是出牌区下方是自己的手牌区。手牌区用JPanel加FlowLayout每张牌用一个自定义的JLabel来画。牌的显示我用的是文字加颜色比如红桃和方块用红色黑桃和梅花用黑色大小王用特殊颜色。如果你想要更美观的效果可以准备一套牌的图片素材用ImageIcon来显示但那样会增加资源文件的管理成本。JPanel handPanel new JPanel(new FlowLayout(FlowLayout.CENTER, 5, 5)); for (Card card : player.getHandCards()) { JLabel cardLabel new JLabel(card.getDisplayName()); cardLabel.setForeground(card.isRed() ? Color.RED : Color.BLACK); cardLabel.setBorder(BorderFactory.createLineBorder(Color.GRAY)); cardLabel.setPreferredSize(new Dimension(50, 70)); handPanel.add(cardLabel); }出牌区用来显示上一手出的牌玩家信息区显示每个玩家的剩余牌数和身份。这些区域用BorderLayout组合起来整体结构很清晰。5.2 鼠标点击选牌与出牌交互的实现选牌交互是界面部分最麻烦的地方。玩家点击一张牌这张牌要上移表示选中再点一下取消选中。出牌时把选中的牌收集起来提交给规则校验。我的做法是给每个牌的JLabel加MouseListener点击时切换选中状态同时调整组件的Y坐标或者加一个偏移量来模拟上移效果。选中的牌用一个Set来记录出牌时遍历这个Set。cardLabel.addMouseListener(new MouseAdapter() { Override public void mouseClicked(MouseEvent e) { if (selectedCards.contains(card)) { selectedCards.remove(card); cardLabel.setLocation(cardLabel.getX(), cardLabel.getY() 15); } else { selectedCards.add(card); cardLabel.setLocation(cardLabel.getX(), cardLabel.getY() - 15); } handPanel.repaint(); } });实操心得Swing的布局管理器会覆盖你手动设置的坐标所以如果你要用setLocation做上移动画必须把布局管理器设为null然后自己计算每张牌的坐标。这虽然麻烦但控制力最强。我试过用布局管理器加边框变化来表示选中效果不如上移直观。5.3 游戏状态刷新与线程安全注意事项Swing是单线程模型所有界面更新必须在事件分发线程EDT里做。如果你在游戏逻辑线程里直接更新界面会出现界面卡死或者显示异常。正确做法是用SwingUtilities.invokeLater把界面更新操作包装起来。SwingUtilities.invokeLater(() - { updateHandPanel(); updatePlayerInfo(); });另外AI出牌如果放在主线程里直接执行会阻塞界面响应。我的做法是用一个简单的延时任务比如用javax.swing.Timer每隔一秒触发一次AI出牌这样界面不会卡玩家也能看到AI出牌的过程体验更自然。6. 常见问题排查与实战避坑指南6.1 牌型判断错误的排查思路牌型判断出错是最常见的问题表现是明明能出的牌提示不能出或者不该能出的牌却能出。排查这类问题我的方法是写单元测试把每种牌型的典型组合和边界组合都测一遍。比如顺子的边界A2345应该判为非法10JQKA应该判为合法JQKA2应该判为非法。连对的边界三连对最小是334455两连对不合法。飞机带翅膀的边界带的牌数量必须和飞机主体匹配。我整理了一个排查速查表问题现象可能原因排查方法顺子判断错误A的点数处理不当检查是否把A当1用了炸弹压不过比较逻辑顺序错误检查炸弹判断是否在类型比较之前三带一识别失败统计映射逻辑有误打印countByRank的结果王炸识别失败王的rank或suit判断错误检查JOKER枚举值6.2 斗牛点数计算的特殊情况处理斗牛点数计算最容易错的地方是A的处理和牛牛的判断。A在斗牛里算1点但Card里A的rank是14如果不做转换直接算结果全错。我的做法是在calculateNiu方法开头统一做点数映射后面所有计算都用映射后的点数。另一个坑是牛牛的判断。剩下两张之和是10或20都是牛牛但有人只判断了等于10的情况漏了20。比如两张都是10点的牌和是20个位是0也是牛牛。这个边界一定要测到。还有一个特殊情况如果五张牌里有多组三张都能凑成10的倍数应该取剩下两张之和最大的那个组合。比如手牌是1,2,3,7,8三张12710剩下3811牛一三张235没有5换一组136没有6。实际上这个例子只有一种组合。但如果有多组要取最优的。我的代码里是找到第一组就返回严格来说应该遍历所有组合取最大这个优化点可以根据需要加上。6.3 界面卡死与响应异常的解决方案界面卡死通常是因为在EDT里执行了耗时操作。比如AI思考如果用了Thread.sleep就会阻塞界面。解决办法是把耗时操作放到独立线程里界面更新用invokeLater回调。另一个常见问题是组件重绘不及时表现为出了牌但界面没更新。这通常是因为修改了数据但没有调用repaint或revalidate。我的习惯是每次数据变更后统一调用一次刷新方法里面包含removeAll、重新添加组件、revalidate、repaint这一套。避坑技巧Swing里动态增删组件后一定要调用revalidate再调用repaint只调repaint有时不够。这个坑我踩过好几次后来养成习惯就再没出过问题。6.4 项目扩展方向与代码重构建议这个项目写完基础版本后有很多可以扩展的方向。比如加入网络对战用Socket实现多人联机比如加入积分系统和排行榜用文件或数据库持久化比如把AI策略升级成基于权重的评估算法让电脑更难打赢。代码重构方面我建议把规则判断部分抽成独立的模块用策略模式管理不同游戏的规则。这样以后加新游戏只需要实现新的策略类不用改现有代码。界面部分也可以抽出一个通用的牌桌框架不同游戏复用同一套界面组件只替换规则和交互逻辑。我在实际重构中发现最早写的那版代码里斗地主和斗牛的牌型判断有不少重复逻辑比如统计点数、判断连续等。这些公共方法应该抽到一个工具类里两边共用。重构后代码量少了将近三分之一维护起来也轻松多了。最后分享一个小技巧调试牌类游戏时把随机种子固定下来然后针对特定牌局写测试用例比随机跑一百遍有效得多。我当初就是靠固定种子复现了一个顺子判断的bug否则那个bug可能跑几千局才出现一次根本抓不到。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进