☰
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. 把某列顶部的牌移到空闲单元(前提:有空单元)
  2. 把某列顶部的牌移到回收单元(前提:花色匹配且点数正好接上)
  3. 把某列顶部的牌移到另一列的顶部(前提:目标列顶部牌的点数比移动牌大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个回收单元。列用什么?空闲单元用什么?回收单元又用什么?

我的选择是:

  • 列:List<List<Card>>,外层List有8个元素,每个元素是一个ArrayList,代表一列。用List而不是Stack,是因为我需要频繁访问"顶部"(即最后一个元素)和"第N张牌",ArrayList的随机访问是O(1)。
  • 空闲单元:Card[],长度4,null表示空。数组比List更轻量,而且空闲单元数量固定,不需要动态扩容。
  • 回收单元:List<Card>[],长度4,每个List只放一种花色。或者更简单,用Map<Suit, List<Card>>,但数组索引访问更快,我选数组。
public class GameState { private final List<List<Card>> columns; private final Card[] freeCells; private final List<Card>[] 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++) { List<Card> 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; List<Card> source = columns.get(fromCol); if (source.size() < numCards) return false; // 检查要移动的牌叠是否合法 List<Card> 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; } } // 检查目标列 List<Card> 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 (List<Card> 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 (List<Card> 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画牌还是用JLabel

Swing里显示一张牌,有两种常见做法:一是自定义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++) { List<Card> 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(List<Card> 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); } }

另外,如果你想让牌局可复现(比如分享给朋友同一个牌局),可以用固定种子的Random:new 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() { PriorityQueue<GameState> queue = new PriorityQueue<>( Comparator.comparingInt(this::evaluate).reversed() ); queue.add(this.copy()); int depth = 0; while (!queue.isEmpty() && depth < 3) { GameState current = queue.poll(); List<Move> 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换成Stage,JPanel换成Pane
  • 把JLabel换成Label或Rectangle+Text
  • 事件处理从MouseListener换成setOnMouseClicked
  • 用Timeline做牌的移动动画

但JavaFX需要额外配置模块化(module-info.java),打包也比Swing麻烦。如果你的目标只是"能玩",Swing足够了;如果想让项目看起来更专业,JavaFX值得投入时间。

7. 我在这项目里学到的几件事

写完这个FreeCell,我最大的收获不是Java语法有多熟练,而是理解了**"规则翻译"这件事的难度**。自然语言描述的规则往往有歧义,比如"颜色相反"到底是指红黑相反还是花色不同?"依次递减"是严格递减还是允许相等?这些细节在写代码时必须一一明确,否则就会出现"明明看着能走却走不了"的bug。

另一个体会是数据结构的选择比算法优化更重要。我一开始用Stack表示列,后来发现需要随机访问第N张牌,换成ArrayList后代码简洁了很多。选对了数据结构,很多操作天然就是O(1)或O(log n),根本不需要复杂的优化。

最后,测试用例要覆盖边界情况。比如空列移动、空闲单元满时的移动、回收单元从空到有牌的移动,这些边界情况最容易出bug。我写了一个简单的测试类,构造各种极端牌局,每次改代码后跑一遍,省了很多手动调试的时间。

如果你也想动手写一个,我的建议是:先别管界面,用命令行把核心逻辑跑通,能自动发牌、能判定移动、能判断胜利。然后再套Swing界面。这样出问题时,你能快速定位是逻辑错了还是界面错了。这个顺序看起来慢,实际上是最快的路径。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询