大家好!“记忆翻牌”(Memory/Concentration Game)是一个老少咸宜的经典益智游戏,也是一个非常适合用来锤炼 GUI 编程技巧的项目。它不仅涉及到基础的布局和事件处理,更能让我们深入实践状态管理、动画效果以及动态 UI 更新等进阶概念。今天,我们就将利用 JavaFX 这个现代化的 Java GUI 框架,从零开始构建一个功能更完善、交互更流畅的记忆翻牌游戏。
这个版本不仅仅是简单的翻牌,我们还将加入:
- 可配置难度: 动态调整网格大小 (4x4, 5x4, 6x4)。
- 丰富卡牌内容: 使用有趣的 Unicode 符号作为牌面。
- 游戏统计: 记录并显示玩家所用的步数和时间。
- 平滑过渡: 使用
PauseTransition
实现翻错牌后的延迟翻回效果。 - 清晰的状态反馈: 实时提示游戏进程、匹配结果。
- 健壮的 UI 更新: 解决动态改变网格大小时常见的布局问题。
无论你是 JavaFX 新手想挑战一个更完整的项目,还是希望深化对 JavaFX 动画、布局和状态管理理解的开发者,本文都将提供详细的实现步骤和深入的技术剖析。
一、 游戏蓝图:核心机制与界面设计
成功的软件始于清晰的设计。
核心游戏机制:
- 初始化 (
initializeGame
):- 根据选定难度 (ROWS, COLS) 确定所需卡牌对数 (
NUM_PAIRS
)。 - 从符号库 (
SYMBOLS
) 中选取足够数量的符号,每种符号准备一对。 - 将所有卡牌值彻底打乱 (
Collections.shuffle
)。 - 创建逻辑卡牌对象 (
Card[][]
) 和对应的 UI 按钮 (Button[][]
),并将打乱后的值赋给卡牌。 - 重置游戏状态变量(步数、匹配对数、计时器等)。
- 关键: 完全重建或替换界面上的卡牌网格 (
GridPane
) 以适应新的行列数,并强制窗口调整大小。
- 根据选定难度 (ROWS, COLS) 确定所需卡牌对数 (
- 玩家翻牌 (
handleCardClick
):- 响应按钮点击事件。进行有效性检查(游戏是否结束?牌是否已翻开/匹配?是否已翻开两张牌?)。
- 如果是第一次有效点击,启动游戏计时器。
- 翻开卡牌(更新
Card
对象的flipped
状态,并调用showCardFace
更新按钮外观)。 - 记录翻开的卡牌(
firstCard
,secondCard
)并更新cardsFaceUp
计数。
- 匹配判断:
- 当翻开第二张牌时,比较
firstCard.getValue()
和secondCard.getValue()
。 - 匹配成功 (
processMatch
): 标记卡牌为matched
,禁用对应按钮,增加matchedPairs
,检查是否胜利 (matchedPairs == NUM_PAIRS
),然后重置回合状态 (resetTurnState
)。 - 匹配失败 (
processMismatch
): 启动一个PauseTransition
,延迟一小段时间后,将两张卡牌翻回(更新flipped
状态,调用hideCardFace
),然后重置回合状态。
- 当翻开第二张牌时,比较
- 游戏结束 (
endGame
): 当matchedPairs
达到NUM_PAIRS
时触发,停止计时器,显示最终结果(用时、步数)。
界面布局规划 (BorderPane
):
- 顶部 (Top): 使用
HBox
容纳难度选择ChoiceBox
、步数Label
(movesLabel
)、时间Label
(timeLabel
)、状态提示Label
(statusLabel
) 和“新游戏”Button
。使用Region
和HBox.setHgrow
实现灵活的间距和对齐。 - 中部 (Center):
GridPane
(cardGridPane
) 用于动态展示卡牌按钮。
二、 JavaFX 技术实现深潜
1. 封装卡牌状态:Card
内部类
良好的封装是复杂系统可维护性的基石。我们定义一个 Card
内部类来管理单张卡牌的所有信息:
private static class Card {
String value; // 牌面符号
boolean flipped = false; // 是否朝上
boolean matched = false; // 是否已配对成功
int row, col; // 在网格中的位置
Card(String value, int row, int col) { /* ... 构造函数 ... */ }
// ... (getter/setter) ...
}
// 主类中持有逻辑和 UI 网格
private Card[][] cards;
private Button[][] cardButtons;
这种方式使得逻辑处理更加清晰,可以直接操作 Card
对象的状态,再根据状态更新对应的 Button
外观。
2. 核心交互逻辑:handleCardClick
与状态控制
handleCardClick
是响应玩家操作的核心,其健壮性至关重要:
private Card firstCard = null;
private Card secondCard = null;
private int cardsFaceUp = 0; // 控制同时翻开的牌数 (0, 1, 或 2)
private void handleCardClick(int row, int col) {
// 1. 前置条件检查 (游戏结束?已匹配?已翻开?等待处理中?)
if (gameOver || cards[row][col].matched || cards[row][col].flipped || cardsFaceUp >= 2) {
return;
}
// 2. 启动计时器 (如果需要)
if (startTimeMillis == 0) { /* ... 启动计时 ... */ }
// 3. 翻开卡牌 & 更新状态
Card clickedCard = cards[row][col];
Button clickedButton = cardButtons[row][col];
clickedCard.flipped = true;
showCardFace(clickedButton, clickedCard); // 更新 UI
cardsFaceUp++;
// 4. 判断是第一张还是第二张
if (firstCard == null) {
firstCard = clickedCard;
} else {
secondCard = clickedCard;
moves++;
movesLabel.setText("步数: " + moves);
// 5. 比较并处理匹配/不匹配
if (firstCard.getValue().equals(secondCard.getValue())) {
processMatch();
} else {
processMismatch();
}
}
}
cardsFaceUp
状态锁: 这是防止用户在动画(翻回卡牌)完成前连续点击的关键。只有当cardsFaceUp < 2
时,点击才有效。当翻开第二张牌后,cardsFaceUp
变为 2,此时点击会被阻止,直到processMatch
或processMismatch
完成并将cardsFaceUp
重置为 0。
3. 平滑动画:PauseTransition
实现延迟翻回
为了让玩家有时间看清翻错的两张牌,我们使用 PauseTransition
来延迟执行翻回操作:
private void processMismatch() {
statusLabel.setText("不匹配!"); /* ... 设置颜色 ... */
PauseTransition pause = new PauseTransition(Duration.seconds(0.8)); // 延迟 0.8 秒
final Card card1 = firstCard; // 保存引用,因为实例变量会被重置
final Card card2 = secondCard;
final Button button1 = cardButtons[card1.row][card1.col];
final Button button2 = cardButtons[card2.row][card2.col];
// 在延迟结束后执行翻回和状态重置
pause.setOnFinished(event -> {
if (!card1.matched) { card1.flipped = false; hideCardFace(button1); }
if (!card2.matched) { card2.flipped = false; hideCardFace(button2); }
resetTurnState(); // 在动画完成后重置回合状态
if (!gameOver) { /* ... 恢复提示信息 ... */ }
});
pause.play(); // 启动延迟
}
// 在匹配成功或失败处理完后调用
private void resetTurnState() {
firstCard = null;
secondCard = null;
cardsFaceUp = 0; // 重置翻牌计数
}
- 保存引用: 由于
PauseTransition
的onFinished
事件处理器是异步执行的,此时主线程可能已经调用了resetTurnState
清空了firstCard
和secondCard
。因此,必须在创建PauseTransition
时,将需要操作的Card
对象和Button
对象保存在final
局部变量中,以便 Lambda 表达式能正确捕获和使用。 - 状态重置时机:
resetTurnState
必须在动画完成之后 (pause.setOnFinished
内部) 调用,这样才能确保在玩家可以进行下一轮翻牌之前,cardsFaceUp
已经被重置为 0。
4. 动态网格重建与窗口自适应(难点解决)
这是让难度选择生效的关键,也是新手容易出错的地方。仅仅清空 GridPane
的子节点是不够的,需要彻底替换或重置 GridPane
实例,并通知窗口调整大小。
private void initializeGame() {
// ... (重置状态和准备 cardValues) ...
// --- 完全重建 GridPane ---
BorderPane root = (BorderPane) cardGridPane.getParent(); // 获取父容器
if (root != null) {
// 1. 创建新的 GridPane
cardGridPane = createCardGridPane();
// 2. 创建逻辑卡牌和 UI 按钮,并添加到新的 GridPane
cards = new Card[ROWS][COLS];
cardButtons = new Button[ROWS][COLS];
// ... (循环创建 Card 和 Button, add 到 cardGridPane) ...
// 3. 将新 GridPane 设置回父容器
root.setCenter(cardGridPane);
BorderPane.setAlignment(cardGridPane, Pos.CENTER);
// 4. 延迟调用窗口大小调整 (解决启动时 getWindow() 为 null 的问题)
javafx.application.Platform.runLater(() -> {
Stage stage = (Stage) root.getScene().getWindow();
if (stage != null) {
stage.sizeToScene(); // 核心:让窗口适应新内容尺寸
// stage.setResizable(false); // 如果需要,在这里重新设置
}
});
} else { /* ... 错误处理 ... */ }
}
- 彻底替换: 不再
clear()
旧网格,而是创建新GridPane
,填充内容,然后用root.setCenter()
将其替换掉旧的。 Platform.runLater()
: 这是解决在start()
方法或其调用的初始化方法中直接操作Stage
可能导致NullPointerException
的标准方法。它确保了stage.sizeToScene()
在 JavaFX Application Thread 的下一轮事件循环中执行,此时窗口和场景的关联已经稳定建立。stage.sizeToScene()
: 强制窗口根据其内部场景(Scene)计算出的最佳尺寸重新调整大小,这对于动态改变GridPane
行列数后能正确显示所有内容至关重要。
5. 游戏计时 (Timeline
)
使用 Timeline
每秒更新一次时间显示,简单直接:
private void setupTimer() {
timerTimeline = new Timeline(new KeyFrame(Duration.seconds(1), event -> {
if (!gameOver && startTimeMillis > 0) { // 确保游戏进行中
long elapsedSeconds = (System.currentTimeMillis() - startTimeMillis) / 1000;
timeLabel.setText("时间: " + elapsedSeconds + " 秒");
}
}));
timerTimeline.setCycleCount(Timeline.INDEFINITE);
// 注意:timerTimeline.play() 是在第一次有效点击时才调用的
}
五、 总结与思考
通过构建这个稍复杂的记忆翻牌游戏,我们不仅实践了 JavaFX 的基本功,更深入理解了:
- 状态管理的重要性: 如何用变量精确控制游戏流程(
cardsFaceUp
防止并发点击,gameOver
控制结束状态)。 - 封装的力量:
Card
类让逻辑更清晰。 - 动画 API 的运用:
PauseTransition
实现平滑的延迟效果,Timeline
处理周期性任务。 - 动态 UI 更新的挑战与解决方案: 如何在改变布局结构(如
GridPane
尺寸)后,确保界面正确刷新并适应内容(完全替换节点 +Platform.runLater
+sizeToScene
)。
这个项目同样可以继续扩展,例如:
- 更丰富的卡牌主题: 使用图片代替符号。
- 动画效果: 加入卡牌翻转的动画。
- 计分系统: 根据时间和步数计算得分。
- 提示功能: 短暂显示所有牌面(扣分或限制次数)。
- 排行榜: 记录不同难度的最佳成绩。
希望这篇深入剖析能帮助你掌握使用 JavaFX 构建复杂交互应用的技巧,并享受编程创造带来的乐趣!
附注: 上述代码片段是说明性的,完整的可运行代码文前资源文件中的java完整示例。确保你的开发环境已正确配置 JavaFX。