【项目二】用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 顺序即可通关!
相关推荐
それども2 小时前
Excel文件解析 - SAX和DOM方式的区别
java·前端·excel
それども2 小时前
Excel文件解析 - SAX startRow cell endRow 执行顺序
java·前端·excel
元亓亓亓2 小时前
LeetCode热题100--169. 多数元素--简单
算法·leetcode·职场和发展
一位搞嵌入式的 genius2 小时前
从 URL 到渲染:JavaScript 性能优化全链路指南
开发语言·前端·javascript·性能优化
ID_180079054732 小时前
Python结合淘宝关键词API进行商品数据挖掘与
开发语言·python·数据挖掘
天天进步20152 小时前
Motia性能进阶与未来:从现有源码推测 Rust 重构之路
开发语言·重构·rust
星空下的月光影子2 小时前
易语言开发从入门到精通:补充篇·办公+桌面自动化深度实战·Word/Excel/PDF联合处理·模拟键鼠·消息推送·定时任务调度
开发语言
兩尛2 小时前
2. 两数相加 c++
开发语言·c++
それども2 小时前
Excel文件解析 - SAX startRow cell endRow 执行时机
java·excel