Java实战:从零构建FreeCell空当接龙游戏
发布时间:2026/10/24 3:30:40 锦皓数字建站

1. 为什么在2024年还要用Java写一个FreeCell很多人第一次听到用Java写FreeCell这个想法时第一反应是这玩意儿不是Windows自带的老古董吗有什么好写的我当初也是这么想的直到某天晚上无聊打开系统自带的空当接龙连玩了二十把突然意识到一个问题——这个看似简单的纸牌游戏背后藏着一套非常优雅的数据结构设计和状态管理逻辑特别适合拿来练手。FreeCell空当接龙和蜘蛛纸牌、扫雷最大的区别在于它几乎不存在运气成分。微软官方曾经统计过经典模式的32000局里有超过99.99%的牌局是可解的。这意味着它是一个纯粹的逻辑推理状态搜索问题而不是靠翻牌碰运气的赌博游戏。对于程序员来说这种确定性恰恰是最好的练手素材——你写的每一行代码都能通过胜负结果得到明确反馈。那为什么选Java而不是Python或者JavaScript原因很实际。Java的强类型系统在建模牌、列、空闲单元这些概念时非常舒服枚举enum和泛型集合能把类型错误扼杀在编译期Swing虽然老但做桌面小游戏零依赖、跨平台一个jar包双击就能跑更重要的是Java的面向对象思维能让你把游戏规则和界面渲染彻底解耦这对理解MVC架构特别有帮助。这篇文章适合谁看如果你已经学过Java基础语法知道什么是类和对象但还没完整做过一个项目那这篇内容就是为你准备的。我会从零开始把FreeCell的规则抽象、数据结构设计、核心算法、界面实现、以及那些只有真正动手才会踩到的坑全部拆开讲清楚。读完你不仅能得到一个能玩的游戏更能理解如何把一个现实规则翻译成代码这件事的完整思路。2. 先把游戏规则翻译成代码语言2.1 FreeCell的规则到底在约束什么在动手写代码之前必须先把游戏规则用程序员能理解的方式重新描述一遍。FreeCell的规则用自然语言说是这样的一副52张的牌去掉大小王开局全部正面朝上发成8列前4列各7张后4列各6张。游戏区域有4个空闲单元Free Cell和4个回收单元Foundation。目标是把所有牌按花色、从A到K的顺序移到回收单元里。但如果你直接照着这段话写代码很快就会乱。正确的做法是把规则拆解成状态和操作两部分。状态包括8个列Column每个列是一个有序的牌堆只有最顶上的牌可以操作4个空闲单元每个单元最多放1张牌4个回收单元每个单元只接受特定花色的牌且必须从A开始按顺序递增操作只有三种把某列顶部的牌移到空闲单元前提有空单元把某列顶部的牌移到回收单元前提花色匹配且点数正好接上把某列顶部的牌移到另一列的顶部前提目标列顶部牌的点数比移动牌大1且颜色相反这里有个关键点容易被忽略移动可以是一叠牌不一定是单张。比如红桃5下面压着黑桃4、红桃3只要这三张满足交替颜色、依次递减就可以整体移动到某列顶部的红桃6下面。这个可移动牌叠的判定是FreeCell区别于其他纸牌游戏的核心机制也是后面算法设计的重点。2.2 用枚举和类给每张牌建立身份Java里表示一张牌最直观的方式是定义一个Card类包含花色Suit和点数Rank两个属性。花色用枚举点数也用枚举这样类型安全不会出现传了个字符串红桃结果拼错的低级错误。public enum Suit { SPADES, HEARTS, DIAMONDS, CLUBS; public boolean isRed() { return this HEARTS || this DIAMONDS; } } 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; } public int getValue() { return value; } }Card类本身很简单但有一个设计决策值得说要不要让Card知道自己的位置我的建议是不要。Card应该是一个纯粹的值对象只描述自己是什么牌不关心自己在哪里。位置信息由外部的游戏状态GameState来维护。这样做的好处是当你需要做撤销操作或者状态快照时只需要复制一份GameState而不需要动Card本身。public class Card { private final Suit suit; private final Rank rank; public Card(Suit suit, Rank rank) { this.suit suit; this.rank rank; } public boolean isRed() { return suit.isRed(); } public Suit getSuit() { return suit; } public Rank getRank() { return rank; } Override public String toString() { return rank of suit; } }这里用final修饰字段保证Card一旦创建就不可变。不可变对象在多线程环境下天然安全虽然FreeCell是单线程游戏但养成这个习惯没坏处。2.3 游戏状态该用什么数据结构承载GameState是整个游戏的核心它需要承载8个列、4个空闲单元、4个回收单元。列用什么空闲单元用什么回收单元又用什么我的选择是列ListListCard外层List有8个元素每个元素是一个ArrayList代表一列。用List而不是Stack是因为我需要频繁访问顶部即最后一个元素和第N张牌ArrayList的随机访问是O(1)。空闲单元Card[]长度4null表示空。数组比List更轻量而且空闲单元数量固定不需要动态扩容。回收单元ListCard[]长度4每个List只放一种花色。或者更简单用MapSuit, ListCard但数组索引访问更快我选数组。public class GameState { private final ListListCard columns; private final Card[] freeCells; private final ListCard[] foundations; public GameState() { columns new ArrayList(8); for (int i 0; i 8; i) { columns.add(new ArrayList()); } freeCells new Card[4]; foundations new List[4]; for (int i 0; i 4; i) { foundations[i] new ArrayList(); } } // 深拷贝用于撤销和AI搜索 public GameState copy() { GameState copy new GameState(); for (int i 0; i 8; i) { copy.columns.get(i).addAll(this.columns.get(i)); } System.arraycopy(this.freeCells, 0, copy.freeCells, 0, 4); for (int i 0; i 4; i) { copy.foundations[i].addAll(this.foundations[i]); } return copy; } }注意copy()方法这是后面实现撤销功能和AI求解的基础。深拷贝意味着新状态和旧状态完全独立修改一个不会影响另一个。虽然拷贝有性能开销但对于52张牌的游戏来说这点开销完全可以接受。3. 核心算法从能走到走得对3.1 合法移动判定三个if背后的逻辑游戏能不能玩全看移动判定写得对不对。我把移动判定拆成三个独立的方法分别对应三种操作。移到空闲单元只要有空位就行逻辑最简单。public boolean canMoveToFreeCell(int columnIndex) { if (columns.get(columnIndex).isEmpty()) return false; for (Card cell : freeCells) { if (cell null) return true; } return false; }移到回收单元需要检查花色和点数。如果回收单元是空的只能放A否则新牌的花色必须和回收单元的花色一致且点数正好比当前顶部牌大1。public boolean canMoveToFoundation(int columnIndex) { if (columns.get(columnIndex).isEmpty()) return false; Card top getTopCard(columnIndex); for (int i 0; i 4; i) { ListCard foundation foundations[i]; if (foundation.isEmpty()) { if (top.getRank() Rank.ACE) return true; } else { Card foundationTop foundation.get(foundation.size() - 1); if (foundationTop.getSuit() top.getSuit() foundationTop.getRank().getValue() 1 top.getRank().getValue()) { return true; } } } return false; }移到另一列这是最复杂的。首先要判断目标列是否为空——空列可以接受任何牌。如果目标列非空则目标列顶部的牌必须比移动牌的点数大1且颜色相反。但还有一个隐藏条件移动的牌叠本身必须是合法的。也就是说从源列顶部往下数连续几张牌必须满足交替颜色、依次递减否则不能整体移动。public boolean canMoveToColumn(int fromCol, int toCol, int numCards) { if (fromCol toCol) return false; ListCard source columns.get(fromCol); if (source.size() numCards) return false; // 检查要移动的牌叠是否合法 ListCard moving source.subList(source.size() - numCards, source.size()); for (int i 0; i moving.size() - 1; i) { Card upper moving.get(i); Card lower moving.get(i 1); if (upper.getRank().getValue() ! lower.getRank().getValue() 1 || upper.isRed() lower.isRed()) { return false; } } // 检查目标列 ListCard target columns.get(toCol); if (target.isEmpty()) return true; Card targetTop target.get(target.size() - 1); Card movingTop moving.get(0); return targetTop.getRank().getValue() movingTop.getRank().getValue() 1 targetTop.isRed() ! movingTop.isRed(); }这里有个容易写错的地方moving.get(0)是牌叠最上面的牌也就是最先移动的牌moving.get(size-1)是最下面的牌。判断颜色交替时是upper和lower比较upper在列表前面但位置在上方。我当初写的时候就把顺序搞反了导致明明合法的移动被判为非法调试了半天。3.2 可移动牌叠数量的计算那个容易被忽略的公式FreeCell有一个非常经典的规则你能移动的牌叠数量取决于当前有多少个空闲单元和空列。公式是最大可移动牌数 (空闲单元数 1) × 2^(空列数)为什么是这个公式想象一下你要把一叠牌从A列移到B列但B列顶部有牌挡着。你可以先把挡路的牌临时挪到空闲单元或空列里腾出空间后再把牌叠移过去最后把临时挪走的牌放回来。每个空闲单元能帮你中转一张牌每个空列能帮你中转一叠牌因为空列可以放任意牌叠而空列的容量又受空闲单元影响所以是指数关系。这个公式直接决定了游戏的可玩性。如果玩家不注意保留空闲单元把4个空闲单元全占满了那他就只能一张一张地移动牌难度陡增。所以在写AI求解器或者提示功能时这个公式是必须实现的。public int getMaxMovableCards() { int freeCellCount 0; for (Card cell : freeCells) { if (cell null) freeCellCount; } int emptyColumnCount 0; for (ListCard col : columns) { if (col.isEmpty()) emptyColumnCount; } return (freeCellCount 1) * (int) Math.pow(2, emptyColumnCount); }注意如果目标列本身就是空的那空列数要减1因为那个空列被用作目标了不能同时用来中转。这个细节在实现自动移动功能时必须考虑。3.3 自动收牌什么时候该把牌送回回收单元玩过FreeCell的人都知道当一张牌可以安全地移到回收单元时游戏通常会自动帮你移过去。但安全的定义是什么不是所有能移的牌都该立刻移。判断标准是如果一张牌移到回收单元后不会阻碍其他牌的必要移动那就移。具体来说对于点数小于等于某个阈值的牌通常可以安全自动收。一个常用的保守策略是A和2永远自动收3到K则要看情况。我实现了一个简化版如果某张牌是A或者它的点数比同花色回收单元顶部大1且没有其他列需要这张牌作为垫牌即没有列顶部是比它小1且颜色相反的牌就自动收。public boolean shouldAutoCollect(int columnIndex) { Card top getTopCard(columnIndex); if (top null) return false; // A永远自动收 if (top.getRank() Rank.ACE) return true; // 检查是否有列需要这张牌 for (ListCard col : columns) { if (col.isEmpty()) continue; Card colTop col.get(col.size() - 1); if (colTop.getRank().getValue() top.getRank().getValue() - 1 colTop.isRed() ! top.isRed()) { return false; // 有列需要它不自动收 } } return canMoveToFoundation(columnIndex); }这个逻辑不是最优的但实测下来足够用。真正完美的自动收牌需要做前瞻搜索那是AI求解器的范畴了。4. 界面实现Swing虽然老但够用4.1 用JPanel画牌还是用JLabelSwing里显示一张牌有两种常见做法一是自定义JPanel重写paintComponent方法用Graphics2D画矩形和文字二是用JLabel设置背景色和文字用布局管理器摆放。我选的是第二种原因很简单开发速度快代码量少。JLabel自带文字居中、边框、背景色设置不需要自己算坐标。每张牌就是一个JLabel设置Opaque为true背景色根据花色设置红色或黑色文字显示点数。public class CardLabel extends JLabel { private final Card card; public CardLabel(Card card) { this.card card; setText(card.getRank().toString()); setOpaque(true); setBackground(card.isRed() ? Color.WHITE : Color.LIGHT_GRAY); setForeground(card.isRed() ? Color.RED : Color.BLACK); setBorder(BorderFactory.createLineBorder(Color.BLACK)); setHorizontalAlignment(SwingConstants.CENTER); setPreferredSize(new Dimension(80, 110)); } public Card getCard() { return card; } }但JLabel有个问题它默认不接收鼠标事件中的拖拽。如果要实现拖拽移动牌需要自己处理MouseListener和MouseMotionListener。我一开始想实现拖拽后来发现点击选择点击目标的方式更简单也更适合新手。所以最终方案是点击源牌高亮点击目标位置执行移动。4.2 布局管理GridBagLayout还是手动定位8列牌每列可能有多张牌叠在一起这种布局用标准的布局管理器很难做。GridBagLayout可以但约束条件写起来很繁琐。我最后用的是null布局绝对定位自己计算每张牌的x、y坐标。public void refreshBoard() { removeAll(); int cardWidth 80; int cardHeight 110; int gap 10; // 绘制8列 for (int col 0; col 8; col) { ListCard column gameState.getColumns().get(col); for (int row 0; row column.size(); row) { CardLabel label new CardLabel(column.get(row)); int x col * (cardWidth gap) 20; int y row * 30 150; // 每张牌向下偏移30像素形成叠放效果 label.setBounds(x, y, cardWidth, cardHeight); add(label); } } // 绘制空闲单元和回收单元 // ... revalidate(); repaint(); }绝对定位的缺点是窗口大小改变时布局会乱但FreeCell的窗口大小通常是固定的所以问题不大。如果你想让窗口可缩放那就得监听ComponentResized事件重新计算所有坐标。4.3 事件处理点击、选中、移动的完整链路事件处理是界面部分最容易出bug的地方。我的设计是维护一个selectedCard变量记录当前选中的牌和它所在的列。点击一张牌时如果selectedCard为空就选中它如果selectedCard不为空就尝试把选中的牌移到当前点击的位置。private CardLabel selectedLabel null; private int selectedColumn -1; label.addMouseListener(new MouseAdapter() { Override public void mouseClicked(MouseEvent e) { if (selectedLabel null) { // 选中 selectedLabel label; selectedColumn col; label.setBorder(BorderFactory.createLineBorder(Color.YELLOW, 3)); } else { // 尝试移动 if (gameState.canMoveToColumn(selectedColumn, col, 1)) { gameState.moveToColumn(selectedColumn, col, 1); selectedLabel null; refreshBoard(); } else { // 移动失败取消选中 selectedLabel.setBorder(BorderFactory.createLineBorder(Color.BLACK)); selectedLabel null; } } } });这里只处理了单张牌的移动。如果要支持多张牌整体移动需要在选中时判断从该牌到列底部的牌叠是否合法如果合法就记录牌叠数量。这个逻辑我放在canMoveToColumn里了调用时传入numCards参数即可。5. 那些只有真正动手才会踩到的坑5.1 牌的初始化洗牌算法别用错的Random发牌之前要洗牌。Java里洗牌的标准做法是Collections.shuffle(list)它内部用的是Fisher-Yates算法效率高且均匀。但如果你自己写洗牌很容易写成每次随机选两张交换这种有偏的算法。我一开始就是自己写的用Math.random()生成两个索引交换结果发现某些牌局出现的概率明显偏高。后来换成Collections.shuffle问题消失。如果你非要自己实现记住正确的Fisher-Yates是从后往前遍历每次和前面包括自己的随机位置交换。public void shuffleDeck(ListCard deck) { Random random new Random(); for (int i deck.size() - 1; i 0; i--) { int j random.nextInt(i 1); Collections.swap(deck, i, j); } }另外如果你想让牌局可复现比如分享给朋友同一个牌局可以用固定种子的Randomnew Random(12345L)。这样每次生成的牌局都一样方便调试和比赛。5.2 空列判定isEmpty和size()0的区别这个问题看起来很小但我确实见过有人写if (column.size() 0)来判断空列然后在某次重构中把column换成了null导致NullPointerException。用isEmpty()更安全而且语义更清晰。更重要的是空列在FreeCell里是稀缺资源。8列里如果有2个空列你的移动能力会大幅提升如果0个空列你只能靠空闲单元周转。所以在AI求解时空列的数量是评估局面好坏的重要指标。我通常在评估函数里给空列很高的权重。5.3 撤销功能深拷贝的性能陷阱撤销功能需要保存历史状态。最简单的做法是每次移动前把整个GameState深拷贝一份压入栈。但深拷贝52张牌的List如果玩家连续撤销100步内存里就有100份完整状态。对于现代计算机来说这不算什么但如果你在移动端跑或者想支持无限撤销就得考虑优化。我的优化方案是记录移动操作而不是状态。每次移动记录一个Move对象包含源位置、目标位置、移动的牌数。撤销时反向执行这个操作即可。这样内存占用极小而且撤销速度更快。public class Move { private final Location from; private final Location to; private final int numCards; public Move(Location from, Location to, int numCards) { this.from from; this.to to; this.numCards numCards; } public Move reverse() { return new Move(to, from, numCards); } }但反向执行有个前提移动操作必须是可逆的。从列移到空闲单元反向就是从空闲单元移回列从列移到回收单元反向就是从回收单元移回列。只要保证移动时没有丢失信息比如牌的顺序反向执行就能完美还原。5.4 胜利判定别等到52张牌都收完才判断胜利条件是4个回收单元都集齐13张牌。但如果你每次移动后都遍历4个List检查size是否为13效率略低。更好的做法是维护一个计数器collectedCount每收一张牌加1等于52时判定胜利。private int collectedCount 0; public void moveToFoundation(int columnIndex) { // ... 执行移动 collectedCount; if (collectedCount 52) { JOptionPane.showMessageDialog(this, 恭喜通关); } }这个优化看起来微不足道但它体现了一个原则能在O(1)时间判断的事情不要用O(n)。游戏循环里每帧都在跑任何小优化累积起来都很可观。6. 让游戏更聪明的几个进阶方向6.1 提示功能用BFS找一步好棋当玩家卡住时一个提示按钮能极大提升体验。提示的本质是在当前所有合法移动中找一步看起来不错的。最简单的实现是随机选一个合法移动但这样提示质量太差。我实现了一个基于广度优先搜索BFS的提示从当前状态出发搜索未来3步内的所有可能状态评估每个状态的分数回收单元牌数×10 空列数×5 空闲单元数×2选分数最高的路径的第一步作为提示。public Move findHint() { PriorityQueueGameState queue new PriorityQueue( Comparator.comparingInt(this::evaluate).reversed() ); queue.add(this.copy()); int depth 0; while (!queue.isEmpty() depth 3) { GameState current queue.poll(); ListMove moves current.getLegalMoves(); for (Move move : moves) { GameState next current.copy(); next.applyMove(move); if (next.isWin()) return move; queue.add(next); } depth; } return queue.peek() ! null ? queue.peek().getLastMove() : null; }这个搜索空间是指数增长的3步还好5步就可能卡顿。所以实际使用时限制深度在3以内并且用优先队列而不是普通队列优先探索看起来有希望的分支。6.2 自动求解A*算法和启发式函数设计如果你想挑战更高难度可以写一个自动求解器。FreeCell的求解本质上是一个状态空间搜索问题A*算法是常用选择。关键是设计一个好的启发式函数h(n)估计从当前状态到胜利还需要多少步。我用的启发式函数是h(n) (52 - collectedCount) blockedCards × 2其中blockedCards是那些被压在下面、无法移动的牌的数量。这个函数不是最优的可能高估但搜索速度可以接受。A*的开放列表用优先队列关闭列表用HashSet需要给GameState实现hashCode和equals。实测下来简单牌局几秒内能解出复杂牌局可能需要几分钟甚至更久。如果想加速可以加入模式数据库——预先计算所有可能的牌型组合的最优解查询时直接匹配。6.3 从Swing到JavaFX界面现代化的路径Swing做出来的界面确实有点上世纪的感觉。如果你想让游戏看起来更现代可以迁移到JavaFX。JavaFX的布局更灵活支持CSS样式动画效果也更流畅。迁移的核心改动是把JFrame换成StageJPanel换成Pane把JLabel换成Label或RectangleText事件处理从MouseListener换成setOnMouseClicked用Timeline做牌的移动动画但JavaFX需要额外配置模块化module-info.java打包也比Swing麻烦。如果你的目标只是能玩Swing足够了如果想让项目看起来更专业JavaFX值得投入时间。7. 我在这项目里学到的几件事写完这个FreeCell我最大的收获不是Java语法有多熟练而是理解了**规则翻译这件事的难度**。自然语言描述的规则往往有歧义比如颜色相反到底是指红黑相反还是花色不同依次递减是严格递减还是允许相等这些细节在写代码时必须一一明确否则就会出现明明看着能走却走不了的bug。另一个体会是数据结构的选择比算法优化更重要。我一开始用Stack表示列后来发现需要随机访问第N张牌换成ArrayList后代码简洁了很多。选对了数据结构很多操作天然就是O(1)或O(log n)根本不需要复杂的优化。最后测试用例要覆盖边界情况。比如空列移动、空闲单元满时的移动、回收单元从空到有牌的移动这些边界情况最容易出bug。我写了一个简单的测试类构造各种极端牌局每次改代码后跑一遍省了很多手动调试的时间。如果你也想动手写一个我的建议是先别管界面用命令行把核心逻辑跑通能自动发牌、能判定移动、能判断胜利。然后再套Swing界面。这样出问题时你能快速定位是逻辑错了还是界面错了。这个顺序看起来慢实际上是最快的路径。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。