【项目二】用GUI编程实现石头迷阵游戏

今天我们用 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();
    }
}

五、关键知识点

  1. 绝对布局(null 布局) :关闭 Swing 默认布局,用**setBounds(x,y,width,height)**自定义组件位置,适合小游戏快速开发。
  2. 二维数组管理数字块:数组的每个元素对应一个数字块图片,数组的顺序就是界面显示顺序,核心逻辑围绕数组操作。
  3. 键盘事件监听 :通过KeyAdapter监听按键,区分上下左右键,实现数字块移动。
  4. 界面刷新 :每次移动后调用**initImage()清空旧组件、绘制新组件,并用repaint()**刷新。
  5. 通关判断:对比当前数组和目标数组,全部匹配则显示胜利提示。

六、拓展思考

1. 乱序有解性问题

我们当前的随机打乱可能导致游戏无解!数字华容道的有解规则:

  • 对于 4x4 的华容道:
    1. 计算逆序数(数组中每个数后面比它小的数的个数之和,不计算 0);
    2. 逆序数为偶数 ,且空白块在偶数行(从下往上数) → 有解;
    3. 逆序数为奇数 ,且空白块在奇数行(从下往上数) → 有解。

2. 可优化的功能

  • 添加鼠标点击移动(代替键盘);
  • 增加难度选择(3x3/4x4/5x5);
  • 添加计时功能,记录通关耗时;
  • 美化界面(按钮样式、字体、颜色);
  • 保存最高分 / 最短时间。

七、运行游戏

  1. 将图片素材放到指定路径(stone-maze/src/image/);
  2. 编译并运行MainFrame类;
  3. 按上下左右键移动数字块,恢复 1-15 顺序即可通关!
相关推荐
yoyo_zzm4 分钟前
Laravel6.x新特性全解析
java·spring boot·后端
AIFarmer10 分钟前
【无标题】
开发语言·c++·算法
Nick_zcy16 分钟前
小说在线阅读网站和小说管理系统 · 功能全解析
java·后端·python·springboot·ruoyi
源码宝18 分钟前
基于 SpringBoot + Vue 的医院随访系统:技术架构与功能实现
java·vue.js·spring boot·架构·源码·随访系统·随访管理
昇腾CANN24 分钟前
TileLang-Ascend 算子性能优化方法与实操
开发语言·javascript·性能优化·昇腾·cann
AGV算法笔记31 分钟前
CVPR 2025 最新感知算法解读:GaussianLSS 如何用 Gaussian Splatting 重构 BEV 表示?
算法·重构·自动驾驶·3d视觉·感知算法·多视角视觉
沐知全栈开发35 分钟前
ionic 手势事件详解
开发语言
lsx2024061 小时前
Bootstrap 按钮
开发语言
qinqinzhang1 小时前
Java 中的 IoC、AOP、MVC
java
神仙别闹1 小时前
基于 Python 实现 BERT 的情感分析模型
开发语言·python·bert