石头迷阵 GUI 综合项目 | JavaSE

石头迷阵 GUI 综合项目

一、项目目标

石头迷阵是 JavaSE 阶段第一个真正具有“游戏状态”的 GUI 综合项目。

它不再只是:

点击按钮
↓
打印一句话

而是:

用户按方向键
    ↓
产生键盘事件
    ↓
修改二维数组
    ↓
更新空白块坐标
    ↓
累计移动步数
    ↓
判断是否通关
    ↓
重新绘制 GUI
    ↓
必要时保存历史记录

项目综合使用:

Swing GUI
二维数组
循环
分支
Random / Math.random
枚举
面向对象
键盘事件
File / IO
异常处理

原教学资料将本项目定位为:

GUI + 二维数组 + 程序流程控制 + 面向对象的综合项目。

本项目完成后,应能够:

  1. 使用二维数组维护游戏状态。
  2. 使用枚举表示有限方向。
  3. 使用 Swing 创建游戏窗口。
  4. 根据二维数组动态绘制 4×4 图片。
  5. 使用键盘事件驱动游戏逻辑。
  6. 根据边界判断是否合法移动。
  7. 实现二维数组中的元素交换。
  8. 判断当前状态是否达到胜利状态。
  9. 统计用户移动步数。
  10. 使用 IO 保存历史最少步数。
  11. 实现重新开始和退出功能。
  12. 理解 GUI 中“数据状态”和“界面显示”之间的关系。

这章真正需要建立的能力是:

界面不是游戏本身,二维数组中的状态才是游戏本身。


二、需求分析

2.1 游戏规则

游戏界面由:

4 × 4

共 16 个位置组成。

其中包含:

1 ~ 15

十五个数字块,以及:

0

一个空白块。

最终胜利状态:

┌────┬────┬────┬────┐
│  1 │  2 │  3 │  4 │
├────┼────┼────┼────┤
│  5 │  6 │  7 │  8 │
├────┼────┼────┼────┤
│  9 │ 10 │ 11 │ 12 │
├────┼────┼────┼────┤
│ 13 │ 14 │ 15 │    │
└────┴────┴────┴────┘

对应:

int[][] winData = {
        {1, 2, 3, 4},
        {5, 6, 7, 8},
        {9, 10, 11, 12},
        {13, 14, 15, 0}
};

2.2 游戏开始

游戏启动后:

  1. 创建窗口。
  2. 读取历史最小步数。
  3. 打乱图块顺序。
  4. 定位空白块。
  5. 绘制游戏界面。
  6. 创建系统菜单。
  7. 注册键盘事件。
  8. 显示窗口。

整体:

App.main()
    ↓
new MainFrame()
    ↓
读取历史成绩
    ↓
初始化窗口
    ↓
打乱数组
    ↓
绘制图片
    ↓
初始化菜单
    ↓
注册键盘事件
    ↓
显示窗口

2.3 移动功能

用户使用:

↑
↓
←
→

移动图块。

注意一个很容易产生歧义的问题:

“按上键”究竟是空白块往上移动,还是下方石块向上进入空白?

原教学代码的 Direction.UP 实际逻辑是:

imageData[row][col]
        ↕
imageData[row + 1][col]

也就是:

空白位置与下方图块交换。

因此理解项目时不要只背:

UP = row - 1

而应该直接看:

哪个位置和空白位置进行交换。


2.4 边界控制

空白块位于:

row = 0

时,其上方已经不存在图块。

位于:

row = 3

时,其下方不存在图块。

同理:

col = 0
col = 3

分别对应左右边界。

所以每次移动之前必须:

判断边界
   ↓
合法
   ↓
交换

而不是先交换再处理越界异常。


2.5 步数统计

每发生一次合法移动:

count++;

非法方向不能增加步数。

例如空白已经位于最右边,再执行一个不能发生的移动:

count 不变

2.6 胜利判断

每次移动以后:

当前 imageData
      ↓
与
      ↓
winData
逐个元素比较

如果:

16 个位置全部相同

说明游戏完成。

此时:

  1. 显示胜利图片。
  2. 比较当前步数与历史最少步数。
  3. 如果本次更少,则写入文件。

2.7 历史纪录

项目使用:

score.txt

保存一个整数:

86

表示:

历史最少使用 86 步通关。

程序启动时:

score.txt
 ↓
BufferedReader
 ↓
String
 ↓
int
 ↓
minCount

刷新最佳成绩时:

count
 ↓
BufferedWriter
 ↓
score.txt

2.8 系统菜单

菜单至少包含:

系统
├── 重启
└── 退出

重启应完成:

重新打乱数组
重新定位空白
count 归零
重新绘制

退出:

关闭当前窗口

三、技术选型

3.1 JDK

本教程统一:

JDK 21

3.2 GUI

使用:

Swing

主要类:

JFrame
JLabel
JMenuBar
JMenu
JMenuItem
ImageIcon

以及:

KeyAdapter
KeyEvent

3.3 游戏状态

使用:

int[][]

保存。

例如:

private final int[][] imageData = {
        {1, 2, 3, 4},
        {5, 6, 7, 8},
        {9, 10, 11, 12},
        {13, 14, 15, 0}
};

为什么不用 16 个变量?

如果设计:

int a1;
int a2;
int a3;
...
int a16;

那么:

遍历
交换
绘制
比较
随机

都会非常麻烦。

二维数组天然对应:

行
+
列

结构。


3.4 方向

使用枚举:

enum Direction

因为方向只有固定的:

UP
DOWN
LEFT
RIGHT

四种状态。

相比:

String direction

枚举具有更明确的取值边界。


3.5 图片

原项目使用:

0.png
1.png
2.png
...
15.png

background.png
win.png

其中:

0.png

表示空白图块。


3.6 文件

使用:

FileReader
BufferedReader

FileWriter
BufferedWriter

保存历史成绩。

这里的数据非常简单:

只有一个整数

所以普通文本文件已经足够。


四、系统设计

4.1 项目结构

可以保持与原项目一致的简单结构:

stone-maze
│
├── src
│   ├── image
│   │   ├── 0.png
│   │   ├── 1.png
│   │   ├── ...
│   │   ├── 15.png
│   │   ├── background.png
│   │   └── win.png
│   │
│   ├── score.txt
│   │
│   └── com
│       └── itheima
│           ├── App.java
│           ├── Direction.java
│           └── MainFrame.java

核心:

App
    → 程序入口

Direction
    → 方向模型

MainFrame
    → GUI + 游戏状态 + 游戏逻辑

4.2 Direction

package com.itheima;

public enum Direction {
    UP,
    DOWN,
    LEFT,
    RIGHT
}

为什么适合枚举?

如果使用:

move(1);
move(2);
move(3);
move(4);

看到:

move(3);

几乎不知道是什么意思。

而:

move(Direction.LEFT);

语义非常明确。


4.3 MainFrame 状态

核心字段:

private final int[][] imageData;
private final int[][] winData;

private int row;
private int col;

private int count;
private int minCount;

可以建立状态模型:

imageData
→ 当前整个棋盘

row + col
→ 当前空白位置

count
→ 当前游戏已经移动多少步

minCount
→ 历史最佳成绩

winData
→ 游戏目标状态

4.4 方法拆分

MainFrame 不应该把全部代码写进构造器。

建议方法结构:

MainFrame()
│
├── initFrame()
├── initRandomArray()
├── initImage()
├── initMenu()
├── initKeyPressEvent()
│
├── switchAndMove()
├── isWin()
│
├── readFileScore()
└── writeFileScore()

每个方法只解决一个主要问题。


4.5 数据流

一次合法移动:

KeyEvent
   ↓
Direction
   ↓
switchAndMove()
   ↓
判断边界
   ↓
imageData 两个元素交换
   ↓
row / col 更新
   ↓
count++
   ↓
initImage()
   ↓
isWin()
   ↓
GUI重新显示

这是整个项目最重要的一条调用链。


五、核心功能实现

5.1 App 启动类

package com.itheima;

import javax.swing.SwingUtilities;

public class App {

    public static void main(String[] args) {

        SwingUtilities.invokeLater(
                MainFrame::new
        );
    }
}

原教学资料的启动方式是:

new MainFrame();

这里在不改变项目结构的前提下,将 Swing 窗口创建提交到 Swing 事件线程。


5.2 MainFrame 基础字段

package com.itheima;

import javax.swing.*;
import java.awt.*;
import java.awt.event.KeyAdapter;
import java.awt.event.KeyEvent;
import java.io.*;

public class MainFrame extends JFrame {

    private static final String IMAGE_PATH =
            "stone-maze/src/image/";

    private static final String SCORE_PATH =
            "stone-maze/src/score.txt";

    private final int[][] imageData = {
            {1, 2, 3, 4},
            {5, 6, 7, 8},
            {9, 10, 11, 12},
            {13, 14, 15, 0}
    };

    private final int[][] winData = {
            {1, 2, 3, 4},
            {5, 6, 7, 8},
            {9, 10, 11, 12},
            {13, 14, 15, 0}
    };

    private int row;
    private int col;

    private int count;
    private int minCount;

5.3 构造器

    public MainFrame() {

        minCount = readFileScore();

        initFrame();

        initRandomArray();

        initImage();

        initMenu();

        initKeyPressEvent();

        setVisible(true);
    }

注意调用顺序。

不能:

先绘制
再打乱

否则第一次显示的可能还是:

胜利状态。

5.4 初始化窗口

    private void initFrame() {

        setTitle("石头迷阵 V1.0");

        setSize(465, 575);

        setDefaultCloseOperation(
                JFrame.EXIT_ON_CLOSE
        );

        setLocationRelativeTo(null);

        setResizable(false);

        setLayout(null);

        setFocusable(true);
    }

本项目图片的位置是固定坐标,因此沿用原项目:

setLayout(null);

也就是:

绝对定位。

这里和上一章的普通表单不同。

对于:

固定大小
固定图片尺寸
小游戏棋盘

绝对定位具有较强直观性。


5.5 打乱二维数组

原项目采用:

随机选择两个位置,然后交换数据。

    private void initRandomArray() {

        for (int i = 0; i < imageData.length; i++) {

            for (int j = 0;
                 j < imageData[i].length;
                 j++) {

                int i1 =
                        (int) (
                                Math.random()
                                * imageData.length
                        );

                int j1 =
                        (int) (
                                Math.random()
                                * imageData.length
                        );

                int i2 =
                        (int) (
                                Math.random()
                                * imageData.length
                        );

                int j2 =
                        (int) (
                                Math.random()
                                * imageData.length
                        );

                int temp =
                        imageData[i1][j1];

                imageData[i1][j1] =
                        imageData[i2][j2];

                imageData[i2][j2] =
                        temp;
            }
        }

        locateBlank();

        count = 0;
    }

5.6 定位空白块

原资料使用带标签的 break 跳出两层循环。

整理成独立方法:

    private void locateBlank() {

        OUT:
        for (int i = 0;
             i < imageData.length;
             i++) {

            for (int j = 0;
                 j < imageData[i].length;
                 j++) {

                if (imageData[i][j] == 0) {

                    row = i;
                    col = j;

                    break OUT;
                }
            }
        }
    }

例如:

1   2   3   4
5   6   7   8
9   0  10  12
13 14  11  15

则:

row = 2
col = 1

5.7 初始化图片

每次状态变化以后,都重新根据:

imageData

绘制。

    private void initImage() {

        getContentPane().removeAll();

        showStepCount();

        if (isWin()) {

            showWinImage();

            updateBestScore();
        }

        for (int i = 0;
             i < imageData.length;
             i++) {

            for (int j = 0;
                 j < imageData[i].length;
                 j++) {

                int number =
                        imageData[i][j];

                String imageName =
                        number + ".png";

                JLabel label =
                        new JLabel(
                                new ImageIcon(
                                        IMAGE_PATH
                                        + imageName
                                )
                        );

                label.setBounds(
                        20 + j * 100,
                        60 + i * 100,
                        100,
                        100
                );

                add(label);
            }
        }

        JLabel background =
                new JLabel(
                        new ImageIcon(
                                IMAGE_PATH
                                + "background.png"
                        )
                );

        background.setBounds(
                0,
                0,
                450,
                484
        );

        add(background);

        repaint();
    }

真正控制显示内容的是:

int number = imageData[i][j];

例如:

imageData[2][3] == 12

那么:

第 2 行第 3 列

就加载:

12.png

5.8 显示步数

    private void showStepCount() {

        JLabel current =
                new JLabel(
                        "当前移动:"
                        + count
                        + " 步"
                );

        current.setBounds(
                20,
                20,
                130,
                20
        );

        current.setForeground(
                Color.RED
        );

        current.setFont(
                new Font(
                        "楷体",
                        Font.BOLD,
                        12
                )
        );

        add(current);

        if (minCount != 0) {

            JLabel best =
                    new JLabel(
                            "最少步数:"
                            + minCount
                            + " 步"
                    );

            best.setBounds(
                    280,
                    20,
                    150,
                    20
            );

            best.setForeground(
                    Color.WHITE
            );

            best.setFont(
                    new Font(
                            "楷体",
                            Font.BOLD,
                            12
                    )
            );

            add(best);
        }
    }

5.9 显示胜利图片

    private void showWinImage() {

        JLabel winLabel =
                new JLabel(
                        new ImageIcon(
                                IMAGE_PATH
                                + "win.png"
                        )
                );

        winLabel.setBounds(
                124,
                230,
                266,
                88
        );

        add(winLabel);
    }

5.10 初始化菜单

    private void initMenu() {

        JMenuBar menuBar =
                new JMenuBar();

        JMenu systemMenu =
                new JMenu("系统");

        JMenuItem restartItem =
                new JMenuItem("重启");

        JMenuItem exitItem =
                new JMenuItem("退出");

        restartItem.addActionListener(
                e -> restartGame()
        );

        exitItem.addActionListener(
                e -> dispose()
        );

        systemMenu.add(restartItem);

        systemMenu.add(exitItem);

        menuBar.add(systemMenu);

        setJMenuBar(menuBar);
    }

5.11 重新开始

重新开始必须意识到:

不只是重新画图片。

至少应该重置:

imageData
row
col
count

在原项目基础上整理:

    private void restartGame() {

        resetToWinState();

        initRandomArray();

        initImage();

        requestFocusInWindow();
    }

恢复初始数组:

    private void resetToWinState() {

        for (int i = 0;
             i < winData.length;
             i++) {

            for (int j = 0;
                 j < winData[i].length;
                 j++) {

                imageData[i][j] =
                        winData[i][j];
            }
        }
    }

然后:

initRandomArray();

重新打乱。


5.12 键盘监听

    private void initKeyPressEvent() {

        addKeyListener(
                new KeyAdapter() {

                    @Override
                    public void keyPressed(
                            KeyEvent e) {

                        switch (
                                e.getKeyCode()
                        ) {

                            case KeyEvent.VK_UP ->
                                    switchAndMove(
                                            Direction.UP
                                    );

                            case KeyEvent.VK_DOWN ->
                                    switchAndMove(
                                            Direction.DOWN
                                    );

                            case KeyEvent.VK_LEFT ->
                                    switchAndMove(
                                            Direction.LEFT
                                    );

                            case KeyEvent.VK_RIGHT ->
                                    switchAndMove(
                                            Direction.RIGHT
                                    );

                            default -> {
                            }
                        }
                    }
                }
        );
    }

这就是上一章:

事件源
↓
KeyEvent
↓
KeyAdapter
↓
keyPressed()

真正进入项目。


5.13 游戏已经胜利后是否继续移动

可以在移动方法开头增加:

if (isWin()) {
    return;
}

于是:

    private void switchAndMove(
            Direction direction) {

        if (isWin()) {
            return;
        }

        switch (direction) {

            case UP -> moveUp();

            case DOWN -> moveDown();

            case LEFT -> moveLeft();

            case RIGHT -> moveRight();
        }

        initImage();
    }

5.14 UP

按照原课程移动语义:

    private void moveUp() {

        if (
                row
                <
                imageData.length - 1
        ) {

            swap(
                    row,
                    col,
                    row + 1,
                    col
            );

            row++;

            count++;
        }
    }

5.15 DOWN

    private void moveDown() {

        if (row > 0) {

            swap(
                    row,
                    col,
                    row - 1,
                    col
            );

            row--;

            count++;
        }
    }

5.16 LEFT

    private void moveLeft() {

        if (
                col
                <
                imageData[0].length - 1
        ) {

            swap(
                    row,
                    col,
                    row,
                    col + 1
            );

            col++;

            count++;
        }
    }

5.17 RIGHT

    private void moveRight() {

        if (col > 0) {

            swap(
                    row,
                    col,
                    row,
                    col - 1
            );

            col--;

            count++;
        }
    }

5.18 抽取交换方法

四个方向都出现:

int temp = ...
...

可以抽取:

    private void swap(
            int row1,
            int col1,
            int row2,
            int col2) {

        int temp =
                imageData[row1][col1];

        imageData[row1][col1] =
                imageData[row2][col2];

        imageData[row2][col2] =
                temp;
    }

此后移动逻辑就从:

一堆数组赋值

提升成:

交换哪个位置

更加接近业务语义。


5.19 胜利判断

    private boolean isWin() {

        for (int i = 0;
             i < imageData.length;
             i++) {

            for (int j = 0;
                 j < imageData[i].length;
                 j++) {

                if (
                        imageData[i][j]
                        !=
                        winData[i][j]
                ) {

                    return false;
                }
            }
        }

        return true;
    }

这里采用:

发现一个不同,立即返回 false

而不是:

无论如何都遍历完 16 个元素

5.20 读取历史最少步数

原项目直接:

Integer.valueOf(line)

训练题特别要求考虑:

文件不存在
文件为空
文件内容非法

因此可以把读取逻辑写得更稳健:

    private int readFileScore() {

        File file =
                new File(SCORE_PATH);

        if (!file.exists()) {
            return 0;
        }

        try (
                FileReader fr =
                        new FileReader(file);

                BufferedReader br =
                        new BufferedReader(fr)
        ) {

            String line =
                    br.readLine();

            if (
                    line == null
                    ||
                    line.isBlank()
            ) {
                return 0;
            }

            int score =
                    Integer.parseInt(
                            line.trim()
                    );

            return Math.max(
                    score,
                    0
            );

        } catch (
                IOException
                |
                NumberFormatException e
        ) {

            System.err.println(
                    "读取历史成绩失败:"
                    + e.getMessage()
            );

            return 0;
        }
    }

这里约定:

0

表示:

暂无历史成绩。


5.21 保存历史最少步数

    private void writeFileScore(
            int score) {

        try (
                FileWriter fw =
                        new FileWriter(
                                SCORE_PATH
                        );

                BufferedWriter bw =
                        new BufferedWriter(fw)
        ) {

            bw.write(
                    String.valueOf(score)
            );

        } catch (IOException e) {

            System.err.println(
                    "保存历史成绩失败:"
                    + e.getMessage()
            );
        }
    }

5.22 更新最佳成绩

    private void updateBestScore() {

        if (
                count <= 0
        ) {
            return;
        }

        if (
                minCount == 0
                ||
                count < minCount
        ) {

            minCount = count;

            writeFileScore(
                    minCount
            );
        }
    }
}

这里需要特别注意:

原教学资料中胜利后会:

writeFileScore(count);

但是字段:

minCount

没有同时更新。

于是即使文件已经更新:

当前界面仍然可能显示旧最佳成绩

直到重新读取。

因此这里明确执行:

minCount = count;

让:

内存状态
+
文件状态

保持一致。


六、关键技术解析

6.1 游戏真正的数据模型是什么

看起来游戏是一堆图片:

1.png
2.png
3.png
...

但图片只是:

View,视图。

真正决定游戏状态的是:

int[][] imageData;

例如:

1  2  3  4
5  6  7  8
9 10  0 12
13 14 11 15

无论界面图片是否绘制:

游戏状态已经确定。

所以正确流程是:

修改数据
  ↓
根据数据
  ↓
刷新界面

而不是:

移动图片
 ↓
再猜数据变成什么

这就是:

数据驱动界面。


6.2 一次移动本质是什么

假设:

1  2  3
4  0  5
6  7  8

空白:

row = 1
col = 1

若把右侧:

5

移动到空白位置,本质就是:

imageData[1][1]
        ↔
imageData[1][2]

也就是数组交换。

GUI 中看起来:

石头移动了

Java 中实际上:

两个 int 交换了。

6.3 为什么还要保存 row 和 col

理论上每移动一次都可以:

重新遍历整个二维数组
找到 0

但是我们已经知道:

0 与哪个位置进行了交换。

所以移动后:

row++;

或者:

col--;

直接维护空白坐标。

这就是:

维护派生状态。

需要特别注意:

如果数组被整体重新打乱:

initRandomArray();

原来的:

row / col

就失效了。

所以必须:

locateBlank();

重新定位。


6.4 合法移动为什么才 count++

错误写法:

count++;

if (可以移动) {
    swap();
}

这样即使撞墙:

步数仍然 +1

正确逻辑:

判断合法
 ↓
交换
 ↓
count++

步数统计描述的是:

真正发生的移动次数。


6.5 为什么每一步都 initImage

imageData 发生变化以后:

旧 JLabel

仍然展示旧图片。

因此必须:

清空界面
   ↓
读取最新 imageData
   ↓
重新创建图片
   ↓
repaint

于是:

switchAndMove(...)

最后调用:

initImage();

完成:

Model变化
↓
View刷新

6.6 菜单事件和键盘事件其实是一回事

菜单:

restartItem.addActionListener(...)

键盘:

keyPressed(...)

表面 API 不同。

本质都是:

用户操作
 ↓
事件
 ↓
监听器
 ↓
业务方法

例如:

点击重启
→ restartGame()

按方向键
→ switchAndMove()

这正是事件驱动模型。


6.7 游戏状态不是一个变量

这个游戏状态实际上由:

imageData
row
col
count
minCount

共同构成。

所以“重新开始”不是:

initImage();

而应该考虑:

数组状态
空白位置
当前步数
界面显示

分别是否需要恢复。

这也是训练题为什么专门要求:

“重新开始功能需要重置哪些变量和界面状态?”


6.8 原项目随机算法的重要限制

原教学资料使用:

随机选择两个位置
↓
交换
↓
重复多次

来产生乱序。

这种做法确实能得到:

随机排列

但原资料自己的拓展部分明确指出:

数字华容道不能随意打乱,否则生成的排列可能无解。

也就是说:

乱
≠
一定有解

这是当前项目必须知道的算法边界。

当前主项目仍保留原教学的随机交换思想。

更严格的可解随机算法放到项目扩展中解决。


6.9 最安全的“可解打乱”思路

如果从:

胜利状态

开始:

1 2 3 4
5 6 7 8
...
13 14 15 0

然后执行:

合法移动
合法移动
合法移动
合法移动
...

例如随机执行 200 次合法移动。

由于每一步都是:

从合法状态通过合法操作到另一个状态,

那么最终状态一定存在一条反向路径:

回到胜利状态。

因此:

从胜利状态通过大量随机合法移动来打乱,是非常适合当前项目的可解方案。

这比直接随机排列 16 个数字更符合游戏规则。


七、测试与 Debug

训练题要求至少设计 10 个测试场景。

7.1 空白在左上角

状态:

0  1  2  3
4  5  6  7
8  9 10 11
12 13 14 15

测试所有方向。

重点:

不能数组越界
非法移动不能 count++

7.2 空白在右下角

1  2  3  4
5  6  7  8
9 10 11 12
13 14 15  0

验证边界。


7.3 连续非法移动

例如空白位于某边界。

连续按:

同一个非法方向 20 次

验证:

程序不崩溃
count 不增长
数组不变化

7.4 正常移动

记录移动前:

imageData
row
col
count

执行一次合法移动。

验证:

恰好两个元素交换
row/col 正确
count + 1

7.5 胜利判断:成功

直接设置:

imageData == winData

逐项内容相同。

预期:

isWin()

返回:

true

7.6 胜利判断:只有一个位置错误

例如:

13 14  0 15

而不是:

13 14 15  0

应该:

false

7.7 score.txt 不存在

删除:

score.txt

启动程序。

预期:

程序仍然可以启动
minCount = 0

7.8 score.txt 为空

文件存在但没有内容。

程序不应该因为:

Integer.parseInt("")

崩溃。


7.9 score.txt 内容非法

写:

abc

程序应该能够恢复为:

暂无历史纪录

而不是整个游戏无法启动。


7.10 新纪录

假设历史:

100 步

本次:

80 步

胜利后:

score.txt → 80
minCount → 80

7.11 未破纪录

历史:

80

本次:

100

文件仍然:

80

7.12 重启

游戏已经移动:

count = 26

点击:

重启

应该:

重新打乱
count = 0
重新定位 row/col
刷新GUI

7.13 一个非常值得 Debug 的原项目问题

原项目菜单重启核心是:

initRandomArray();
initImage();

如果打乱方法没有负责:

count = 0;

那么:

新的一局可能继承上一局移动步数。

因此重新开始时必须明确设计:

哪些状态保留
哪些状态归零

历史:

minCount

应该保留。

当前:

count

应该归零。


7.14 键盘无响应

如果方向键没有反应,需要检查:

窗口是否拥有键盘焦点
KeyListener 是否注册
事件是否真的到达 MainFrame

可以尝试:

requestFocusInWindow();

特别是点击菜单以后,要关注焦点是否发生变化。


7.15 图片不显示

检查:

路径
文件名
扩展名
工作目录

例如:

1.png

和:

01.png

不是同一个文件。


八、项目扩展

8.1 可解随机打乱

第一优先级扩展。

算法:

恢复胜利状态
 ↓
重复 N 次
 ↓
随机选择一个合法方向
 ↓
移动空白
 ↓
得到乱序状态

这样生成的局面必然可以恢复。


8.2 防止随机后立即胜利

如果打乱结果仍然:

isWin() == true

继续进行合法随机移动。

否则用户一启动:

恭喜通关

属实有点像游戏给玩家发了“免试保送”。


8.3 难度等级

例如:

简单:随机合法移动 30 次
普通:100 次
困难:300 次

注意:

随机移动次数并不严格等于最优解步数。

它只能作为一种粗粒度难度控制。


8.4 计时器

增加:

开始时间
结束时间

最终显示:

步数:88
时间:73 秒

进一步历史纪录可以考虑:

最少步数
最快时间

8.5 撤销功能

保存每次移动前状态:

previousStates

可以使用:

Deque<int[][]>

或者记录:

Direction

形成历史操作栈。

点击:

撤销

回到上一步。

这样可以综合训练:

栈
集合
数组深拷贝

8.6 保存当前游戏

不只是保存:

minCount

还可以保存:

imageData
row
col
count

关闭游戏以后:

继续上次游戏

这会进一步训练:

IO + 状态持久化。

8.7 排行榜

从:

只有一个最佳成绩

扩展成:

张三  66
李四  72
王五  85

使用:

List<Record>
+
Comparator
+
IO

完成排行榜。


8.8 将 MainFrame 拆分

当前 MainFrame 同时负责:

GUI
游戏逻辑
文件读写

项目规模变大以后,可以拆成:

MainFrame
    → GUI

GameModel
    → imageData / row / col / count

GameService
    → move / restart / isWin

ScoreRepository
    → score.txt

结构:

MainFrame
    ↓
GameService
    ↓
GameModel

ScoreRepository
    ↓
score.txt

这和上一章学生系统的:

界面
业务
数据

分层思想开始统一。


九、项目验收

本项目不能以:

运行起来了

作为验收标准。

9.1 第一关:理解项目

关闭代码,能够画出:

App
 ↓
MainFrame
 ↓
initFrame
initRandomArray
initImage
initMenu
initKeyPressEvent

并解释每个方法的职责。


9.2 第二关:字段设计

不给源代码,只告诉你:

做一个 4×4 数字华容道。

能够自己设计:

int[][] imageData;
int[][] winData;

int row;
int col;

int count;
int minCount;

并说明为什么需要它们。


9.3 第三关:核心算法

独立手写:

swap(...)

和:

switchAndMove(...)

必须能够正确处理:

四个方向
四条边界
合法移动
非法移动
步数
空白坐标

9.4 第四关:胜利判断

独立实现:

boolean isWin()

要求:

不使用固定 16 个 if

而是通过二维数组遍历完成。


9.5 第五关:GUI

能够解释:

为什么每一次移动之后都要刷新 GUI?

答案不能只说:

因为代码就是这么写的。

而应该解释:

imageData 是真实游戏状态,GUI 是其显示结果。数组发生变化以后,界面必须根据新的数组重新绘制。


9.6 第六关:IO

独立完成:

读取最少步数
写入最少步数
文件不存在
文件为空
文件非法

的处理。


9.7 第七关:重启

能够完整回答:

点击“重新开始”时到底应该重置什么?

至少包括:

imageData
row
col
count
GUI

而:

minCount

不应该被删除。


9.8 第八关:Debug

独立设计至少 10 个测试场景:

四方向边界
合法移动
非法移动
胜利
非胜利
文件不存在
文件为空
文件非法
新纪录
重新开始

9.9 第九关:闭卷实现

关闭:

教程
原项目
ChatGPT
AI代码补全

只保留需求:

开发一个 4×4 石头迷阵 GUI 游戏,使用二维数组保存状态,方向键控制图块移动,支持重新开始、胜利判断、移动步数统计和历史最佳成绩保存。

然后按照:

1. 写需求
2. 画数据结构
3. 写字段
4. 写方法清单
5. 写移动算法
6. 写GUI
7. 写事件
8. 写IO
9. 测试

独立完成。

如果这一步可以做到,说明你已经开始从:

学 Java API

真正进入:

使用 Java 构建程序。