1. 从零拆解一个Java棋牌游戏项目的整体设计思路
做Java桌面棋牌游戏这件事,我前前后后折腾过好几轮。最早是帮朋友的一个小工作室做内部娱乐工具,后来自己也拿它当练手项目反复重构。斗地主和斗牛这两个玩法放在同一个项目里,看起来只是两个独立的小游戏,但真正动手之后你会发现,它们共享了大量的底层能力——牌面建模、发牌逻辑、牌型判断、胜负结算、界面渲染。如果一开始不把这些公共部分抽出来,代码会迅速膨胀成一团乱麻。
这个项目的核心目标很明确:用Java实现斗地主和斗牛两款经典棋牌游戏,支持本地运行、人机对战或多人轮流操作,具备完整的发牌、出牌、牌型识别、胜负判定和界面交互能力。它解决的问题其实很实际——很多初学者学完Java基础后找不到合适的练手项目,做管理系统太枯燥,做算法题又缺乏工程感,而棋牌游戏恰好卡在一个甜点位上:逻辑复杂度适中、有明确的规则约束、能练到面向对象设计、集合操作、算法判断和GUI编程。
适合谁来参考?如果你已经掌握了Java的基本语法,能写类、会用集合、了解一点Swing或JavaFX,那这个项目非常适合你。它不需要数据库、不需要网络编程、不需要框架,纯Java SE就能跑起来。但如果你连面向对象的基本概念都还没理清,建议先把继承、多态、接口这些过一遍再回来。
我选择的技术方案是Java SE + Swing。为什么不用JavaFX?Swing虽然老,但胜在资料多、坑少、不需要额外配置模块化依赖,对于一个小型桌面游戏来说完全够用。为什么不做网络版?网络版涉及Socket通信、多线程同步、断线重连等一堆问题,会把项目复杂度拉高一个量级,不适合作为入门到进阶的过渡项目。先把单机版做扎实,后续想扩展成联机版也有清晰的路径。
整个项目的架构我分成了四层:牌面模型层负责定义扑克牌、牌堆、花色、点数;规则引擎层负责牌型识别、大小比较、出牌合法性校验;游戏流程层负责控制回合流转、发牌、叫地主、结算;界面层负责渲染手牌、响应点击、显示提示信息。这四层之间通过接口解耦,斗地主和斗牛各自实现自己的规则引擎,但共用牌面模型和界面基础组件。
注意:不要一上来就写界面。我见过太多人打开IDE就开始拖按钮,结果逻辑和界面缠在一起,改一个规则要动五个文件。先把牌面模型和规则引擎用纯逻辑写出来,用main方法测试通过之后再接界面,效率会高很多。
2. 牌面模型与核心数据结构的细节设计
2.1 扑克牌怎么建模才合理
一张扑克牌有两个核心属性:花色和点数。花色用枚举表示最合适,因为它的取值是固定的四种。点数从3到2共13个值,但斗地主里大小顺序和斗牛不一样,所以点数的比较逻辑不能写死在枚举里,要交给各自的规则引擎处理。
public enum Suit { SPADE, HEART, CLUB, DIAMOND } public enum Rank { THREE(3), FOUR(4), FIVE(5), SIX(6), SEVEN(7), EIGHT(8), NINE(9), TEN(10), JACK(11), QUEEN(12), KING(13), ACE(14), TWO(15), JOKER_SMALL(16), JOKER_BIG(17); private final int value; Rank(int value) { this.value = value; } public int getValue() { return value; } }这里有个细节:大小王没有花色,但为了统一建模,我给它们分配了SPADE花色,在显示时特殊处理。这样做的好处是牌堆可以用统一的List 管理,不需要为大小王单独开一个类型。
Card类本身很简单,持有Suit和Rank两个字段,重写equals和hashCode方便后续比较和去重。toString方法用来调试和界面显示。
public 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; } @Override public int compareTo(Card other) { return Integer.compare(this.rank.getValue(), other.rank.getValue()); } @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 Objects.hash(suit, rank); } }2.2 牌堆的构建与洗牌算法
一副牌54张,斗地主用整副,斗牛通常只用52张去掉大小王。牌堆的构建逻辑很直接:双重循环遍历花色和点数,再加上大小王。
洗牌我用的是Collections.shuffle(),底层是Fisher-Yates算法,时间复杂度O(n),对于54张牌来说性能完全不是问题。但这里有个坑:如果你在发牌过程中反复调用shuffle,会导致牌序不可复现,调试的时候很痛苦。我的做法是在洗牌前把随机种子固定下来,调试模式下用固定种子,正式运行再用随机种子。
public class Deck { private final List<Card> cards; public Deck(boolean includeJokers) { cards = new ArrayList<>(); for (Suit suit : Suit.values()) { for (Rank rank : Rank.values()) { if (rank == Rank.JOKER_SMALL || rank == Rank.JOKER_BIG) continue; cards.add(new Card(suit, rank)); } } if (includeJokers) { cards.add(new Card(Suit.SPADE, Rank.JOKER_SMALL)); cards.add(new Card(Suit.SPADE, Rank.JOKER_BIG)); } } public void shuffle(Random random) { Collections.shuffle(cards, random); } public Card draw() { if (cards.isEmpty()) throw new IllegalStateException("牌堆已空"); return cards.remove(cards.size() - 1); } }实操心得:发牌时用remove(cards.size() - 1)从尾部取牌,比remove(0)效率高。ArrayList头部删除需要移动所有后续元素,虽然54张牌影响不大,但养成好习惯没坏处。
2.3 玩家手牌的排序与展示
玩家拿到手牌后需要排序,斗地主和斗牛的排序规则不同。斗地主按点数从大到小排,方便玩家快速识别牌型;斗牛通常按点数分组,方便计算牛几。我的做法是在Player类里持有一个List ,提供一个sort方法,具体比较器由游戏规则决定。
public class Player { private final String name; private final List<Card> hand; public Player(String name) { this.name = name; this.hand = new ArrayList<>(); } public void sortHand(Comparator<Card> comparator) { hand.sort(comparator); } public void addCard(Card card) { hand.add(card); } public List<Card> getHand() { return Collections.unmodifiableList(hand); } }界面展示时,手牌需要重叠排列,否则17张牌横着放屏幕根本放不下。我的做法是计算每张牌的偏移量,让它们部分重叠,鼠标悬停时再弹起来。这个交互细节后面在界面章节会详细说。
3. 斗地主规则引擎的完整实现
3.1 牌型识别的核心算法
斗地主的牌型有十几种:单张、对子、三张、三带一、三带二、顺子、连对、飞机、炸弹、火箭、四带二等等。识别牌型的思路是:先统计每种点数出现的次数,然后根据次数分布来判断。
public class DouDizhuRuleEngine { public CardType recognize(List<Card> cards) { if (cards == null || cards.isEmpty()) return CardType.INVALID; Map<Rank, Integer> countMap = new TreeMap<>(); for (Card card : cards) { countMap.merge(card.getRank(), 1, Integer::sum); } int size = cards.size(); List<Integer> counts = new ArrayList<>(countMap.values()); Collections.sort(counts, Collections.reverseOrder()); // 火箭:大小王 if (size == 2 && countMap.containsKey(Rank.JOKER_SMALL) && countMap.containsKey(Rank.JOKER_BIG)) { return CardType.ROCKET; } // 炸弹:四张同点数 if (size == 4 && counts.get(0) == 4) { return CardType.BOMB; } // 单张 if (size == 1) return CardType.SINGLE; // 对子 if (size == 2 && counts.get(0) == 2) return CardType.PAIR; // 三张 if (size == 3 && counts.get(0) == 3) return CardType.TRIPLE; // 三带一 if (size == 4 && counts.get(0) == 3 && counts.get(1) == 1) { return CardType.TRIPLE_WITH_SINGLE; } // 三带二 if (size == 5 && counts.get(0) == 3 && counts.get(1) == 2) { return CardType.TRIPLE_WITH_PAIR; } // 顺子:至少5张连续单牌 if (size >= 5 && isConsecutive(countMap, 1)) { return CardType.STRAIGHT; } // 连对:至少3对连续 if (size >= 6 && size % 2 == 0 && isConsecutive(countMap, 2)) { return CardType.CONSECUTIVE_PAIRS; } // 飞机、飞机带翅膀等复杂牌型... // 此处省略部分分支,完整实现需要处理所有合法组合 return CardType.INVALID; } private boolean isConsecutive(Map<Rank, Integer> countMap, int expectedCount) { List<Rank> ranks = new ArrayList<>(countMap.keySet()); // 排除2和大小王,它们不能参与顺子 for (Rank rank : ranks) { if (rank.getValue() >= Rank.TWO.getValue()) return false; if (countMap.get(rank) != expectedCount) return false; } Collections.sort(ranks); for (int i = 1; i < ranks.size(); i++) { if (ranks.get(i).getValue() - ranks.get(i - 1).getValue() != 1) { return false; } } return true; } }这段代码的关键在于countMap的构建和counts的排序。用TreeMap而不是HashMap是为了让点数有序,方便后续判断顺子。counts降序排列后,counts.get(0)就是出现次数最多的那个点数,很多牌型判断都依赖这个。
注意:顺子判断里必须排除2和大小王。斗地主规则中2不能参与顺子,这是新手最容易写错的地方。我当初调试的时候,发现3-4-5-6-7能识别,但10-J-Q-K-A也能识别,唯独A-2-3-4-5不行,查了半天才发现是2的问题。
3.2 牌型大小比较的逻辑
牌型识别出来之后,比较大小是另一个核心问题。规则是:同类型牌型才能比较,炸弹和火箭可以压任何非炸弹牌型,大炸弹压小炸弹。
public boolean canBeat(List<Card> current, List<Card> previous) { if (previous == null || previous.isEmpty()) return true; CardType currentType = recognize(current); CardType previousType = recognize(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 != CardType.BOMB && previousType == CardType.BOMB) return false; if (currentType == CardType.BOMB && previousType == CardType.BOMB) { return getMaxRank(current) > getMaxRank(previous); } // 同类型比较 if (currentType != previousType) return false; if (current.size() != previous.size()) return false; return getMaxRank(current) > getMaxRank(previous); } private Rank getMaxRank(List<Card> cards) { return cards.stream() .map(Card::getRank) .max(Comparator.comparingInt(Rank::getValue)) .orElseThrow(); }这里有个容易忽略的点:顺子和连对的比较不仅要看最大牌,还要看长度。比如3-4-5-6-7和4-5-6-7-8-9,后者长度更长,不能互相比较。我在canBeat里加了size判断,确保只有长度相同的同类型牌才能比较。
3.3 叫地主与回合流转
叫地主的逻辑相对简单:三个玩家轮流叫分,叫分最高的当地主,如果都不叫则重新发牌。我实现了一个简单的状态机来管理回合流转。
public enum GamePhase { DEALING, BIDDING, PLAYING, GAME_OVER } public class DouDizhuGame { private GamePhase phase; private List<Player> players; private Player landlord; private Player currentPlayer; private List<Card> lastPlayed; private Player lastPlayer; public void nextTurn() { int currentIndex = players.indexOf(currentPlayer); currentPlayer = players.get((currentIndex + 1) % players.size()); // 如果轮回到上次出牌的玩家,说明其他人都不要,可以重新出牌 if (currentPlayer == lastPlayer) { lastPlayed = null; } } public boolean playCards(Player player, List<Card> cards) { if (player != currentPlayer) return false; if (!player.getHand().containsAll(cards)) return false; if (!ruleEngine.canBeat(cards, lastPlayed)) return false; player.getHand().removeAll(cards); lastPlayed = cards; lastPlayer = player; if (player.getHand().isEmpty()) { phase = GamePhase.GAME_OVER; } else { nextTurn(); } return true; } }nextTurn里的逻辑是斗地主的核心回合机制:如果下一个玩家就是上次出牌的玩家,说明中间所有人都选择了"不要",此时清空lastPlayed,让该玩家自由出牌。这个判断条件看似简单,但如果没有处理好,会出现玩家无法出牌或者可以无限压牌的bug。
4. 斗牛游戏的规则实现与特殊处理
4.1 斗牛的点数计算与牛几判定
斗牛和斗地主的规则差异很大。斗牛每人5张牌,需要从中选出3张牌使它们的点数之和为10的倍数,剩下的2张牌点数之和的个位数就是"牛几"。如果剩下2张之和也是10的倍数,那就是"牛牛",最大的牌型。
点数计算有个特殊规则:J、Q、K都算10点,A算1点,其他牌按面值算。这和斗地主的点数体系完全不同,所以斗牛需要自己的点数映射。
public class NiuNiuRuleEngine { public int getPoint(Card card) { Rank rank = card.getRank(); if (rank == Rank.JOKER_SMALL || rank == Rank.JOKER_BIG) { return 0; // 斗牛通常不用大小王 } if (rank.getValue() >= Rank.TEN.getValue()) { return 10; } return rank.getValue(); } public NiuNiuType calculateNiu(List<Card> cards) { if (cards.size() != 5) return NiuNiuType.INVALID; int[] points = cards.stream().mapToInt(this::getPoint).toArray(); // 枚举所有3张牌的组合 for (int i = 0; i < 5; i++) { for (int j = i + 1; j < 5; j++) { for (int k = j + 1; k < 5; k++) { int sum3 = points[i] + points[j] + points[k]; if (sum3 % 10 != 0) continue; // 剩下2张牌 int sum2 = 0; for (int m = 0; m < 5; m++) { if (m != i && m != j && m != k) { sum2 += points[m]; } } int niu = sum2 % 10; if (niu == 0) return NiuNiuType.NIU_NIU; return NiuNiuType.values()[niu]; // NIU_1 到 NIU_9 } } } return NiuNiuType.NO_NIU; } }这段代码用三重循环枚举所有3张牌的组合,虽然看起来有点笨,但5张牌总共只有C(5,3)=10种组合,性能完全没问题。找到第一组满足条件的3张牌后,计算剩下2张的和即可。
实操心得:斗牛里有个容易搞混的地方——如果存在多组3张牌之和为10的倍数,应该取哪种结果?标准规则是取最大的牛几。比如同时能找到牛5和牛8的组合,应该算牛8。我最初的实现找到第一组就返回了,导致结果不稳定。后来改成遍历所有组合,记录最大的牛几,才符合规则。
4.2 特殊牌型的倍数计算
斗牛里有些特殊牌型会翻倍:牛牛通常3倍,五花牛(全是JQK)4倍,五小牛(所有牌点数都小于5且总和不超过10)5倍。这些倍数规则需要在结算时处理。
public int getMultiplier(List<Card> cards, NiuNiuType type) { if (type == NiuNiuType.NIU_NIU) return 3; boolean allFace = cards.stream().allMatch(c -> c.getRank().getValue() >= Rank.JACK.getValue() && c.getRank().getValue() <= Rank.KING.getValue()); if (allFace) return 4; int total = cards.stream().mapToInt(this::getPoint).sum(); boolean allSmall = cards.stream().allMatch(c -> getPoint(c) < 5); if (allSmall && total <= 10) return 5; return 1; }五花牛的判断要注意:大小王不算JQK,如果牌堆里包含大小王,需要先排除。五小牛的判断是每张牌点数小于5且总和不超过10,这两个条件缺一不可。
4.3 多人比牌与庄家轮转
斗牛通常是多人游戏,有一个庄家,其他玩家和庄家比牌。比牌规则是:先比牛几,牛几大的赢;牛几相同比最大单张牌,最大单张相同比花色。庄家轮转可以用简单的取模运算实现。
public class NiuNiuGame { private List<Player> players; private int bankerIndex; public void settle() { Player banker = players.get(bankerIndex); NiuNiuType bankerType = ruleEngine.calculateNiu(banker.getHand()); for (Player player : players) { if (player == banker) continue; NiuNiuType playerType = ruleEngine.calculateNiu(player.getHand()); int result = compareNiu(playerType, player.getHand(), bankerType, banker.getHand()); if (result > 0) { // 闲家赢 int multiplier = ruleEngine.getMultiplier(player.getHand(), playerType); transferChips(banker, player, multiplier); } else if (result < 0) { // 庄家赢 int multiplier = ruleEngine.getMultiplier(banker.getHand(), bankerType); transferChips(player, banker, multiplier); } // result == 0 平局,不转移筹码 } bankerIndex = (bankerIndex + 1) % players.size(); } }比牌时的花色比较顺序通常是黑桃>红桃>梅花>方块,这个顺序没有统一标准,可以根据实际需求调整。我在实现时把它做成了可配置的,方便不同地区的玩法差异。
5. 界面交互与用户体验的实操细节
5.1 手牌渲染与鼠标交互
Swing里渲染手牌,我用的自定义JPanel加重写paintComponent。每张牌画成一个圆角矩形,中间显示点数和花色。手牌重叠排列,鼠标悬停时该牌上移。
public class HandPanel extends JPanel { private List<Card> cards; private int hoverIndex = -1; private static final int CARD_WIDTH = 80; private static final int CARD_HEIGHT = 110; private static final int OVERLAP = 30; public HandPanel() { addMouseMotionListener(new MouseAdapter() { @Override public void mouseMoved(MouseEvent e) { int index = getCardIndexAt(e.getX(), e.getY()); if (index != hoverIndex) { hoverIndex = index; repaint(); } } }); } private int getCardIndexAt(int x, int y) { for (int i = cards.size() - 1; i >= 0; i--) { int cardX = i * OVERLAP; if (x >= cardX && x <= cardX + CARD_WIDTH && y >= 0 && y <= CARD_HEIGHT) { return i; } } return -1; } @Override protected void paintComponent(Graphics g) { super.paintComponent(g); Graphics2D g2d = (Graphics2D) g; g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); for (int i = 0; i < cards.size(); i++) { int x = i * OVERLAP; int y = (i == hoverIndex) ? -15 : 0; drawCard(g2d, cards.get(i), x, y); } } }getCardIndexAt从后往前遍历,因为后面的牌覆盖在前面的牌上面,点击时应该优先选中视觉上在最上层的牌。这个细节如果不注意,会出现点击响应和视觉不一致的问题。
5.2 出牌区域的动画与提示
出牌后,牌需要从手牌区域飞到出牌区域。我用了一个简单的Timer动画,每帧更新牌的位置,到达目标位置后停止。
public void animatePlay(List<Card> cards, Point from, Point to) { Timer timer = new Timer(16, null); final int totalFrames = 15; final int[] frame = {0}; timer.addActionListener(e -> { frame[0]++; float progress = (float) frame[0] / totalFrames; float eased = 1 - (1 - progress) * (1 - progress); // ease-out int currentX = (int) (from.x + (to.x - from.x) * eased); int currentY = (int) (from.y + (to.y - from.y) * eased); updateCardPosition(cards, currentX, currentY); repaint(); if (frame[0] >= totalFrames) { timer.stop(); onAnimationComplete(cards); } }); timer.start(); }16毫秒一帧大约是60FPS,15帧总共250毫秒左右,动画不会太慢也不会太快。ease-out的缓动函数让动画结尾减速,看起来更自然。
注意:Swing的Timer回调在EDT线程上执行,不要在回调里做耗时操作,否则界面会卡顿。如果需要在动画结束后执行复杂逻辑,用SwingUtilities.invokeLater包一层。
5.3 界面布局与适配
整个游戏界面用BorderLayout分成几个区域:中间是出牌区,底部是玩家手牌区,左右和上方是其他玩家信息区。窗口大小变化时,需要重新计算各区域的位置和尺寸。
public class GameFrame extends JFrame { private CardLayout cardLayout; private JPanel mainPanel; public GameFrame() { setTitle("棋牌游戏"); setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); setSize(1024, 768); setLocationRelativeTo(null); cardLayout = new CardLayout(); mainPanel = new JPanel(cardLayout); mainPanel.add(createMenuPanel(), "menu"); mainPanel.add(createDouDizhuPanel(), "doudizhu"); mainPanel.add(createNiuNiuPanel(), "niuniu"); add(mainPanel); cardLayout.show(mainPanel, "menu"); } }用CardLayout管理不同界面之间的切换,比手动setVisible方便得多。菜单界面选择玩法,点击后切换到对应的游戏面板。
6. 常见问题与排查技巧实录
6.1 牌型识别错误的排查思路
牌型识别出错是最常见的问题。我的排查步骤是:先打印出countMap的内容,确认每种点数出现的次数是否正确;再检查counts排序后的顺序是否符合预期;最后逐个分支检查判断条件。
| 问题现象 | 可能原因 | 排查方法 |
|---|---|---|
| 顺子识别失败 | 包含2或大小王 | 检查isConsecutive里是否排除了2和大小王 |
| 三带一识别成炸弹 | counts判断顺序错误 | 先判断炸弹再判断三带一 |
| 连对识别失败 | 未检查每对数量是否为2 | 确认countMap中每个值都等于2 |
| 飞机识别失败 | 未处理带翅膀的情况 | 分别判断飞机不带、带单、带对 |
6.2 界面卡顿与闪烁的优化
Swing界面卡顿通常有两个原因:在EDT线程里做了耗时操作,或者频繁repaint导致重绘风暴。我的优化经验是:所有游戏逻辑放在独立线程里计算,计算完成后用SwingUtilities.invokeLater更新界面;重绘时只repaint需要更新的区域,而不是整个面板。
// 错误做法:在EDT里做耗时计算 button.addActionListener(e -> { List<Card> aiCards = aiPlayer.calculateBestMove(); // 可能耗时 updateUI(aiCards); }); // 正确做法:后台线程计算,EDT更新界面 button.addActionListener(e -> { new Thread(() -> { List<Card> aiCards = aiPlayer.calculateBestMove(); SwingUtilities.invokeLater(() -> updateUI(aiCards)); }).start(); });实操心得:开启双缓冲可以显著减少闪烁。在JPanel的构造函数里调用setDoubleBuffered(true),Swing会自动在内存里先画好再一次性输出到屏幕。这个设置对于频繁重绘的棋牌游戏界面效果很明显。
6.3 AI出牌逻辑的简化实现
如果不想写复杂的AI,可以用简单的贪心策略:优先出最小的单张或对子,能压就压,不能压就过。虽然不聪明,但至少能跑起来。
public List<Card> simpleAI(List<Card> hand, List<Card> lastPlayed) { if (lastPlayed == null || lastPlayed.isEmpty()) { // 自由出牌,出最小的单张 Card smallest = hand.stream() .min(Comparator.comparingInt(c -> c.getRank().getValue())) .orElseThrow(); return Collections.singletonList(smallest); } // 尝试找能压过的牌 CardType lastType = ruleEngine.recognize(lastPlayed); for (Card card : hand) { List<Card> candidate = Collections.singletonList(card); if (ruleEngine.canBeat(candidate, lastPlayed)) { return candidate; } } return Collections.emptyList(); // 过牌 }这个AI只会在单张层面做判断,不会拆牌也不会配合队友,但作为占位实现完全够用。后续想提升AI水平,可以引入牌型拆分、手牌评估、蒙特卡洛搜索等策略。
6.4 游戏状态保存与恢复
单机游戏也需要保存进度,比如玩家中途退出后下次继续。我用Java序列化把游戏状态写到文件里,简单粗暴但有效。
public class GameState implements Serializable { private static final long serialVersionUID = 1L; private List<Card> deck; private List<Player> players; private GamePhase phase; private int currentPlayerIndex; // ... 其他状态字段 } public void saveGame(GameState state, String filePath) throws IOException { try (ObjectOutputStream oos = new ObjectOutputStream( new FileOutputStream(filePath))) { oos.writeObject(state); } } public GameState loadGame(String filePath) throws IOException, ClassNotFoundException { try (ObjectInputStream ois = new ObjectInputStream( new FileInputStream(filePath))) { return (GameState) ois.readObject(); } }序列化时要注意:所有被序列化的类都必须实现Serializable接口,包括Card、Player、GameState。如果某个字段不需要序列化,用transient修饰。
7. 项目扩展方向与个人实操体会
这个项目做完基础版本后,有很多可以继续深挖的方向。比如把斗地主的AI从贪心策略升级成基于手牌评估的搜索算法,让电脑对手更有挑战性;比如给斗牛增加下注和筹码系统,模拟真实的赌桌体验;比如把界面从Swing换成JavaFX,获得更现代的视觉效果和更好的动画支持。
我自己在实际操作中的体会是,棋牌游戏项目最大的价值不在于最终做出来的成品有多完善,而在于过程中被迫解决的每一个具体问题。牌型识别逼着你把集合操作和条件判断练到熟练,回合流转逼着你理解状态机的设计,界面交互逼着你处理事件驱动编程的各种边界情况。这些东西看教程是学不会的,只有自己一行一行写出来、一个bug一个bug调过去,才能真正变成自己的东西。
最后再分享一个小技巧:写规则引擎的时候,把每个牌型的判断逻辑单独写成一个方法,用单元测试覆盖所有边界情况。比如顺子的测试用例要包含最短顺子、最长顺子、包含2的非法顺子、包含大小王的非法顺子。测试通过之后再集成到游戏流程里,能省掉大量调试时间。我当初就是靠这套测试用例,在重构规则引擎时保证了行为一致性,没有引入回归bug。