今天我们用 Java Swing 做一个经典的石头迷阵小游戏,学会窗口搭建、数组操作、键盘事件处理、通关判断等核心知识点。
目录
[1. 开发环境](#1. 开发环境)
[2. 资源准备](#2. 资源准备)
[步骤 1:搭建游戏主窗口](#步骤 1:搭建游戏主窗口)
[步骤 2:初始化菜单和步数标签](#步骤 2:初始化菜单和步数标签)
[步骤 3:绘制游戏界面](#步骤 3:绘制游戏界面)
[步骤 4:打乱数字块顺序(核心)](#步骤 4:打乱数字块顺序(核心))
[步骤 5:绑定键盘事件](#步骤 5:绑定键盘事件)
[1. 乱序有解性问题](#1. 乱序有解性问题)
[2. 可优化的功能](#2. 可优化的功能)
一、游戏效果预览

- 初始界面:4x4 的数字块矩阵,搭配背景图,顶部有系统菜单
- 操作方式:按上下左右方向键移动数字块,空白块(0)会和相邻数字块交换
- 通关判定:数字块恢复 1-15 的顺序后,显示胜利提示
- 额外功能:统计移动步数、重启游戏、退出游戏
二、准备工作
1. 开发环境
- JDK 21
- 本人使用IDEA集成开发环境。
2. 资源准备
在项目目录下创建图片文件夹(路径:stone-maze/src/image/),准备以下图片素材:
- 数字块图片:1.png-15.png
- 空白块占位:0.png(透明 / 空白图)
- 背景图片:background.png
- 胜利提示:win.png
三、分步实现游戏核心功能
步骤 1:搭建游戏主窗口
首先创建MainFrame类,继承 Swing 的JFrame(窗口类),初始化窗口的基础属性(标题、大小、布局等)。
java
package com.itheima;
import javax.swing.*;
import java.awt.event.KeyAdapter;
import java.awt.event.KeyEvent;
// 游戏主窗口类,继承JFrame
public class MainFrame extends JFrame {
// 图片路径常量(根据自己的项目结构调整)
public static final String IMAGE_PATH = "stone-maze/src/image/";
// 核心:用二维数组管理数字块(0代表空白块)
private int[][] imageData = {
{1, 2, 3, 4},
{5, 6, 7, 8},
{9, 10, 11, 12},
{13, 14, 15, 0}
};
// 目标通关数组(用于判断是否胜利)
private int[][] winData = {
{1, 2, 3, 4},
{5, 6, 7, 8},
{9, 10, 11, 12},
{13, 14, 15, 0}
};
// 记录空白块的位置(行/列)
private int emptyRow;
private int emptyCol;
// 统计移动步数
private int stepCount = 0;
// 步数展示标签
private JLabel stepLabel;
// 构造方法:初始化游戏
public MainFrame() {
// 1. 初始化窗口基础属性
initFrame();
// 2. 初始化菜单(退出/重启)
initMenu();
// 3. 初始化步数标签
initStepLabel();
// 4. 打乱数字块顺序
initRandomArray();
// 5. 绘制游戏界面(数字块+背景)
initImage();
// 6. 绑定键盘事件(上下左右移动)
bindKeyEvent();
// 最后设置窗口可见
this.setVisible(true);
}
// 初始化窗口基础属性
private void initFrame() {
this.setTitle("石头迷阵 v1.0"); // 窗口标题
this.setSize(465, 575); // 窗口大小(宽x高)
this.setLocationRelativeTo(null); // 窗口居中
this.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); // 关闭窗口即退出程序
this.setLayout(null); // 关闭默认布局,使用绝对定位(方便自定义组件位置)
this.setResizable(false); // 禁止窗口缩放
}
public static void main(String[] args) {
// 启动游戏
new MainFrame();
}
}
步骤 2:初始化菜单和步数标签
添加顶部系统菜单(退出 + 重启),并在界面上显示移动步数:
java
// 初始化系统菜单(退出/重启)
private void initMenu() {
JMenuBar menuBar = new JMenuBar(); // 菜单条
JMenu sysMenu = new JMenu("系统菜单"); // 菜单
JMenuItem exitItem = new JMenuItem("退出游戏"); // 菜单项1:退出
JMenuItem restartItem = new JMenuItem("重启游戏"); // 菜单项2:重启
// 退出游戏事件
exitItem.addActionListener(e -> this.dispose()); // 销毁窗口,退出程序
// 重启游戏事件
restartItem.addActionListener(e -> {
stepCount = 0; // 重置步数
stepLabel.setText("移动步数:" + stepCount); // 更新步数显示
initRandomArray(); // 重新打乱数组
initImage(); // 重新绘制界面
});
// 组装菜单
sysMenu.add(exitItem);
sysMenu.add(restartItem);
menuBar.add(sysMenu);
this.setJMenuBar(menuBar); // 把菜单条添加到窗口
}
// 初始化步数标签
private void initStepLabel() {
stepLabel = new JLabel("移动步数:" + stepCount);
stepLabel.setBounds(20, 20, 150, 30); // 位置(x,y)+ 大小(宽x高)
stepLabel.setFont(new java.awt.Font("微软雅黑", java.awt.Font.PLAIN, 16)); // 字体样式
this.add(stepLabel);
}
步骤 3:绘制游戏界面
根据imageData数组绘制数字块和背景图,每次移动后都会重新调用这个方法刷新界面:
java
// 绘制游戏界面(数字块+背景)
private void initImage() {
// 先清空原有组件(避免重复绘制)
this.getContentPane().removeAll();
// 重新添加步数标签(清空后需要重新加)
this.add(stepLabel);
// 遍历二维数组,绘制每个数字块
for (int i = 0; i < imageData.length; i++) {
for (int j = 0; j < imageData[i].length; j++) {
// 拼接图片路径(比如:1.png、0.png)
String imgName = imageData[i][j] + ".png";
ImageIcon icon = new ImageIcon(IMAGE_PATH + imgName);
JLabel imgLabel = new JLabel(icon);
// 设置数字块位置:每行/列间隔100px,左边距20,上边距60
imgLabel.setBounds(20 + j * 100, 60 + i * 100, 100, 100);
this.add(imgLabel); // 添加到窗口
}
}
// 绘制背景图(放在最后,避免覆盖数字块)
JLabel bgLabel = new JLabel(new ImageIcon(IMAGE_PATH + "background.png"));
bgLabel.setBounds(0, 0, 450, 484);
this.add(bgLabel);
// 通关判断:如果通关,显示胜利图片
if (isWin()) {
JLabel winLabel = new JLabel(new ImageIcon(IMAGE_PATH + "win.png"));
winLabel.setBounds(124, 230, 266, 88); // 胜利图居中显示
this.add(winLabel);
}
// 刷新界面(必须调用,否则组件不更新)
this.repaint();
}
// 判断是否通关:对比当前数组和目标数组
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; // 全部匹配,通关!
}
步骤 4:打乱数字块顺序(核心)
随机打乱数组,但注意:不是所有乱序都有解(后续拓展会讲),这里先做基础随机打乱:
java
// 打乱数字块顺序
private void initRandomArray() {
// 循环交换随机位置的元素(打乱次数越多,越乱)
for (int i = 0; i < 100; i++) {
// 生成两个随机位置
int r1 = (int) (Math.random() * 4);
int c1 = (int) (Math.random() * 4);
int r2 = (int) (Math.random() * 4);
int c2 = (int) (Math.random() * 4);
// 交换两个位置的数字
int temp = imageData[r1][c1];
imageData[r1][c1] = imageData[r2][c2];
imageData[r2][c2] = temp;
}
// 找到空白块(0)的位置,记录到emptyRow/emptyCol
for (int i = 0; i < imageData.length; i++) {
for (int j = 0; j < imageData[i].length; j++) {
if (imageData[i][j] == 0) {
emptyRow = i;
emptyCol = j;
break; // 找到后直接退出循环
}
}
}
}
步骤 5:绑定键盘事件
监听键盘的上下左右键,让空白块和相邻数字块交换位置:
java
// 绑定键盘事件(上下左右移动)
private void bindKeyEvent() {
this.addKeyListener(new KeyAdapter() {
@Override
public void keyPressed(KeyEvent e) {
// 如果已经通关,禁止移动
if (isWin()) {
return;
}
int keyCode = e.getKeyCode(); // 获取按键编码
switch (keyCode) {
case KeyEvent.VK_UP: // 上键:空白块和下方数字块交换
moveUp();
break;
case KeyEvent.VK_DOWN: // 下键:空白块和上方数字块交换
moveDown();
break;
case KeyEvent.VK_LEFT: // 左键:空白块和右侧数字块交换
moveLeft();
break;
case KeyEvent.VK_RIGHT: // 右键:空白块和左侧数字块交换
moveRight();
break;
}
}
});
}
// 上键逻辑:空白块向下移动(数字块向上)
private void moveUp() {
// 边界判断:空白块不能在最后一行(否则没下方的块可交换)
if (emptyRow < 3) {
// 交换空白块和下方块的数值
imageData[emptyRow][emptyCol] = imageData[emptyRow + 1][emptyCol];
imageData[emptyRow + 1][emptyCol] = 0;
// 更新空白块位置
emptyRow++;
// 步数+1并更新显示
stepCount++;
stepLabel.setText("移动步数:" + stepCount);
// 刷新界面
initImage();
}
}
// 下键逻辑:空白块向上移动(数字块向下)
private void moveDown() {
if (emptyRow > 0) {
imageData[emptyRow][emptyCol] = imageData[emptyRow - 1][emptyCol];
imageData[emptyRow - 1][emptyCol] = 0;
emptyRow--;
stepCount++;
stepLabel.setText("移动步数:" + stepCount);
initImage();
}
}
// 左键逻辑:空白块向右移动(数字块向左)
private void moveLeft() {
if (emptyCol < 3) {
imageData[emptyRow][emptyCol] = imageData[emptyRow][emptyCol + 1];
imageData[emptyRow][emptyCol + 1] = 0;
emptyCol++;
stepCount++;
stepLabel.setText("移动步数:" + stepCount);
initImage();
}
}
// 右键逻辑:空白块向左移动(数字块向右)
private void moveRight() {
if (emptyCol > 0) {
imageData[emptyRow][emptyCol] = imageData[emptyRow][emptyCol - 1];
imageData[emptyRow][emptyCol - 1] = 0;
emptyCol--;
stepCount++;
stepLabel.setText("移动步数:" + stepCount);
initImage();
}
}
四、完整代码
把以上所有方法整合到MainFrame类中,补充缺失的导入包,最终完整代码如下:
java
package com.itheima;
import javax.swing.*;
import java.awt.event.KeyAdapter;
import java.awt.event.KeyEvent;
public class MainFrame extends JFrame {
public static final String IMAGE_PATH = "stone-maze/src/image/";
private int[][] imageData = {
{1, 2, 3, 4},
{5, 6, 7, 8},
{9, 10, 11, 12},
{13, 14, 15, 0}
};
private int[][] winData = {
{1, 2, 3, 4},
{5, 6, 7, 8},
{9, 10, 11, 12},
{13, 14, 15, 0}
};
private int emptyRow;
private int emptyCol;
private int stepCount = 0;
private JLabel stepLabel;
public MainFrame() {
initFrame();
initMenu();
initStepLabel();
initRandomArray();
initImage();
bindKeyEvent();
this.setVisible(true);
}
private void initFrame() {
this.setTitle("石头迷阵 v1.0");
this.setSize(465, 575);
this.setLocationRelativeTo(null);
this.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
this.setLayout(null);
this.setResizable(false);
}
private void initMenu() {
JMenuBar menuBar = new JMenuBar();
JMenu sysMenu = new JMenu("系统菜单");
JMenuItem exitItem = new JMenuItem("退出游戏");
JMenuItem restartItem = new JMenuItem("重启游戏");
exitItem.addActionListener(e -> this.dispose());
restartItem.addActionListener(e -> {
stepCount = 0;
stepLabel.setText("移动步数:" + stepCount);
initRandomArray();
initImage();
});
sysMenu.add(exitItem);
sysMenu.add(restartItem);
menuBar.add(sysMenu);
this.setJMenuBar(menuBar);
}
private void initStepLabel() {
stepLabel = new JLabel("移动步数:" + stepCount);
stepLabel.setBounds(20, 20, 150, 30);
stepLabel.setFont(new java.awt.Font("微软雅黑", java.awt.Font.PLAIN, 16));
this.add(stepLabel);
}
private void initImage() {
this.getContentPane().removeAll();
this.add(stepLabel);
for (int i = 0; i < imageData.length; i++) {
for (int j = 0; j < imageData[i].length; j++) {
String imgName = imageData[i][j] + ".png";
ImageIcon icon = new ImageIcon(IMAGE_PATH + imgName);
JLabel imgLabel = new JLabel(icon);
imgLabel.setBounds(20 + j * 100, 60 + i * 100, 100, 100);
this.add(imgLabel);
}
}
JLabel bgLabel = new JLabel(new ImageIcon(IMAGE_PATH + "background.png"));
bgLabel.setBounds(0, 0, 450, 484);
this.add(bgLabel);
if (isWin()) {
JLabel winLabel = new JLabel(new ImageIcon(IMAGE_PATH + "win.png"));
winLabel.setBounds(124, 230, 266, 88);
this.add(winLabel);
}
this.repaint();
}
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;
}
private void initRandomArray() {
for (int i = 0; i < 100; i++) {
int r1 = (int) (Math.random() * 4);
int c1 = (int) (Math.random() * 4);
int r2 = (int) (Math.random() * 4);
int c2 = (int) (Math.random() * 4);
int temp = imageData[r1][c1];
imageData[r1][c1] = imageData[r2][c2];
imageData[r2][c2] = temp;
}
for (int i = 0; i < imageData.length; i++) {
for (int j = 0; j < imageData[i].length; j++) {
if (imageData[i][j] == 0) {
emptyRow = i;
emptyCol = j;
break;
}
}
}
}
private void bindKeyEvent() {
this.addKeyListener(new KeyAdapter() {
@Override
public void keyPressed(KeyEvent e) {
if (isWin()) {
return;
}
int keyCode = e.getKeyCode();
switch (keyCode) {
case KeyEvent.VK_UP:
moveUp();
break;
case KeyEvent.VK_DOWN:
moveDown();
break;
case KeyEvent.VK_LEFT:
moveLeft();
break;
case KeyEvent.VK_RIGHT:
moveRight();
break;
}
}
});
}
private void moveUp() {
if (emptyRow < 3) {
imageData[emptyRow][emptyCol] = imageData[emptyRow + 1][emptyCol];
imageData[emptyRow + 1][emptyCol] = 0;
emptyRow++;
stepCount++;
stepLabel.setText("移动步数:" + stepCount);
initImage();
}
}
private void moveDown() {
if (emptyRow > 0) {
imageData[emptyRow][emptyCol] = imageData[emptyRow - 1][emptyCol];
imageData[emptyRow - 1][emptyCol] = 0;
emptyRow--;
stepCount++;
stepLabel.setText("移动步数:" + stepCount);
initImage();
}
}
private void moveLeft() {
if (emptyCol < 3) {
imageData[emptyRow][emptyCol] = imageData[emptyRow][emptyCol + 1];
imageData[emptyRow][emptyCol + 1] = 0;
emptyCol++;
stepCount++;
stepLabel.setText("移动步数:" + stepCount);
initImage();
}
}
private void moveRight() {
if (emptyCol > 0) {
imageData[emptyRow][emptyCol] = imageData[emptyRow][emptyCol - 1];
imageData[emptyRow][emptyCol - 1] = 0;
emptyCol--;
stepCount++;
stepLabel.setText("移动步数:" + stepCount);
initImage();
}
}
public static void main(String[] args) {
new MainFrame();
}
}
五、关键知识点
- 绝对布局(null 布局) :关闭 Swing 默认布局,用**setBounds(x,y,width,height)**自定义组件位置,适合小游戏快速开发。
- 二维数组管理数字块:数组的每个元素对应一个数字块图片,数组的顺序就是界面显示顺序,核心逻辑围绕数组操作。
- 键盘事件监听 :通过KeyAdapter监听按键,区分上下左右键,实现数字块移动。
- 界面刷新 :每次移动后调用**initImage()清空旧组件、绘制新组件,并用repaint()**刷新。
- 通关判断:对比当前数组和目标数组,全部匹配则显示胜利提示。
六、拓展思考
1. 乱序有解性问题
我们当前的随机打乱可能导致游戏无解!数字华容道的有解规则:
- 对于 4x4 的华容道:
- 计算逆序数(数组中每个数后面比它小的数的个数之和,不计算 0);
- 逆序数为偶数 ,且空白块在偶数行(从下往上数) → 有解;
- 逆序数为奇数 ,且空白块在奇数行(从下往上数) → 有解。
2. 可优化的功能
- 添加鼠标点击移动(代替键盘);
- 增加难度选择(3x3/4x4/5x5);
- 添加计时功能,记录通关耗时;
- 美化界面(按钮样式、字体、颜色);
- 保存最高分 / 最短时间。
七、运行游戏

- 将图片素材放到指定路径(stone-maze/src/image/);
- 编译并运行MainFrame类;
- 按上下左右键移动数字块,恢复 1-15 顺序即可通关!