石头迷阵 GUI 综合项目 | JavaSE
石头迷阵 GUI 综合项目
一、项目目标
石头迷阵是 JavaSE 阶段第一个真正具有“游戏状态”的 GUI 综合项目。
它不再只是:
点击按钮
↓
打印一句话
而是:
用户按方向键
↓
产生键盘事件
↓
修改二维数组
↓
更新空白块坐标
↓
累计移动步数
↓
判断是否通关
↓
重新绘制 GUI
↓
必要时保存历史记录
项目综合使用:
Swing GUI
二维数组
循环
分支
Random / Math.random
枚举
面向对象
键盘事件
File / IO
异常处理
原教学资料将本项目定位为:
GUI + 二维数组 + 程序流程控制 + 面向对象的综合项目。
本项目完成后,应能够:
- 使用二维数组维护游戏状态。
- 使用枚举表示有限方向。
- 使用 Swing 创建游戏窗口。
- 根据二维数组动态绘制 4×4 图片。
- 使用键盘事件驱动游戏逻辑。
- 根据边界判断是否合法移动。
- 实现二维数组中的元素交换。
- 判断当前状态是否达到胜利状态。
- 统计用户移动步数。
- 使用 IO 保存历史最少步数。
- 实现重新开始和退出功能。
- 理解 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 游戏开始
游戏启动后:
- 创建窗口。
- 读取历史最小步数。
- 打乱图块顺序。
- 定位空白块。
- 绘制游戏界面。
- 创建系统菜单。
- 注册键盘事件。
- 显示窗口。
整体:
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 个位置全部相同
说明游戏完成。
此时:
- 显示胜利图片。
- 比较当前步数与历史最少步数。
- 如果本次更少,则写入文件。
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 构建程序。