从零手写Java飞机躲障碍游戏|Swing绘图、鼠标跟随、计时器碰撞检测实战(四)

本文基于 Java Swing 从零实现一款飞机躲避障碍物小游戏。上期我们完成了游戏窗体搭建、鼠标控制飞机移动、积分计时器基础框架。本期主要解决背景音乐接入问题,同时踩坑学习 IDEA 资源文件加载的各种细节。适合 Java Swing 初学者阅读。

上期回顾

上一期我们搭建了游戏的基础运行框架,具体完成了以下工作:

  • 完成了游戏主窗体与自定义绘图面板的搭建,固定了窗体大小并禁止缩放。
  • 通过 MouseMotionListener 监听鼠标移动,实现了飞机跟随鼠标光标移动。
  • 使用 Swing 的 Timer 计时器,实现了游戏计时、积分统计,并在鼠标移动时重绘游戏画面。
  • 实现了点击【开始游戏】按钮后隐藏按钮,从而切换游戏运行状态。

✨本期功能实现

本期我们将为游戏添加两大核心功能:碰撞检测与游戏结束逻辑 以及背景音乐的播放与控制。同时,我们还将深入探讨在 IntelliJ IDEA 中管理资源文件的正确方法。

1. 碰撞检测与游戏结束逻辑

我们将实现一个简单的矩形碰撞检测算法,用于判断飞机(或子弹)与障碍物是否发生碰撞。当碰撞发生时,游戏状态将切换为"结束",并触发相应的视觉反馈(例如显示"游戏结束"文字)和逻辑处理(例如停止生成新障碍物、停止计时器等)。这部分逻辑将主要实现在 DrawPanel 类的 paintComponent 方法和游戏主循环中。

2. 背景音乐的接入与控制

为了提升游戏体验,我们将为游戏添加循环播放的背景音乐。具体实现包括以下步骤:

  • 资源准备 :将 .wav 格式的背景音乐文件(例如我们选用的《The Wind Sings(且听风吟)》)放置在项目的 resources 目录下。
  • 音频播放 :学习使用 Java 标准库中的 Clip 音频组件来加载和播放音频。
  • 播放控制:实现点击【开始游戏】按钮后开始播放音乐,游戏结束时自动停止播放,并确保音乐能够平滑地循环播放。
3. 掌握 IDEA 资源管理

在实现音频功能的过程中,我们会"踩坑"并深入学习 IntelliJ IDEA 中资源文件的正确使用方式:

  • resources 文件夹的作用 :了解为何要将音乐、图片等资源文件放在 src/main/resources(或 src/resources)目录下,以及它如何被编译输出到 outtarget/classes 目录。
  • getResourceAsStream 的原理 :深入理解 ClassLoader.getResourceAsStream() 方法如何从类路径(classpath)加载资源,这与直接使用绝对磁盘路径 FileInputStream 有何本质区别。
  • 路径问题排查:厘清开发时项目路径、编译后输出路径以及程序运行时类路径之间的关系,避免常见的"找不到文件"错误。
4. 学习使用 Java Clip 组件

我们将详细学习 javax.sound.sampled.Clip 的使用方法,包括:

  • 如何打开一个音频输入流(AudioInputStream)。
  • 如何加载并播放 .wav 文件。
  • 如何设置循环播放(Clip.LOOP_CONTINUOUSLY)。
  • 如何实现暂停、继续播放和从头开始播放等控制功能。

通过本期的实践,你不仅能实现游戏的核心功能,还能掌握 Java 桌面应用开发中资源管理和音频处理的重要技能。

前期未看不要紧,每行代码都有注释哦~

好了,让我们开始今天的代码之旅吧!

1.Main类,Bullet类,Obstacles类保持不变

Main类

功能说明

  • 程序入口 main 方法 :作为程序的启动点,负责创建 Main 类的实例并调用其界面显示方法。
  • 窗口设置 :创建 JFrame 主窗口,设置标题为"飞行器游戏",固定窗口尺寸为 600×800 像素,禁止用户调整大小,并将窗口居中显示。同时,设置关闭窗口时自动退出程序。
  • 绘图面板创建 :实例化自定义的 DrawPanel 绘图面板,将其大小设置为与窗口一致(铺满整个窗体),并将其添加到主窗口中。
  • 事件监听器绑定 :创建 GameListener 监听器对象(传入绘图面板以更新游戏状态),并将其绑定到窗口的鼠标移动事件上,从而实现飞机跟随鼠标移动的功能。最后,将窗口设置为可见状态,完成界面初始化。

职责定位

Main 类专注于游戏界面的初始化和窗口管理,不涉及具体的游戏逻辑(如碰撞检测、分数计算等),这些逻辑由其他类(如 DrawPanelGameListener)负责。

java 复制代码
import javax.swing.*;

public class Main {
    
    static void main() {
        // 创建 Main 类的实例
        Main main = new Main();
        // 调用实例方法,显示游戏主界面
        main.showUI();
    }

    /**
     * 创建并显示游戏主窗口(用户界面)。
     */
    public void showUI() {
        // 基本显示
        // 创建游戏主窗口,标题为"飞行器游戏"
        JFrame jf = new JFrame("飞行器游戏");
        // 设置窗口大小为 600x800 像素
        jf.setSize(600, 800);
        // 禁止用户调整窗口大小,保持固定尺寸
        jf.setResizable(false);
        // 将窗口置于屏幕中央显示
        jf.setLocationRelativeTo(null);
        // 设置窗口关闭按钮的行为:点击关闭按钮时退出整个程序
        jf.setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE);
        // 设置窗口的布局管理器为 null(绝对定位),便于手动设置组件位置和大小
        jf.setLayout(null);

        // 创建自定义绘图面板(DrawPanel),用于绘制游戏画面(飞机、障碍物、子弹等)
        DrawPanel drawPanel = new DrawPanel();
        // 设置绘图面板在窗口中的位置和大小:左上角(0,0),宽600,高800(铺满整个窗口)
        drawPanel.setBounds(0, 0, 600, 800);
        // 将绘图面板添加到主窗口中
        jf.add(drawPanel);

        // 创建游戏监听器(GameListener),并将绘图面板传入,以便监听器能更新游戏状态
        GameListener gameListener = new GameListener(drawPanel);
        // 为窗口添加鼠标移动监听器,实现飞机跟随鼠标移动的功能
        jf.addMouseMotionListener(gameListener);

        // 将窗口设置为可见,显示游戏界面
        jf.setVisible(true);
    }
}

Bullet类

属性

  • x, y:子弹的坐标(以像素为单位)。
  • width, height:子弹的固定宽度和高度(分别为 8 像素和 16 像素)。
  • isAlive :布尔类型,标记子弹是否存活(true 表示存活,false 表示已消失或击中目标)。

构造方法

  • 创建 Bullet 对象时,需传入子弹生成的初始坐标 (x, y)
  • 默认 isAlive = true,表示新创建的子弹初始状态为存活。

生命周期管理

子弹在飞出屏幕边界或击中障碍物后,其 isAlive 属性会被设置为 false,从而完成销毁逻辑,不再参与后续的游戏循环。

java 复制代码
public class Bullet {
    // 子弹的横坐标(以像素为单位)
    public int x;
    // 子弹的纵坐标(以像素为单位)
    public int y;
    // 子弹的宽度,固定为8像素
    public int width = 8;
    // 子弹的高度,固定为16像素
    public int height = 16;
    // 子弹是否存活(true表示存活,false表示已消失或击中目标)
    public boolean isAlive;

    /**
     * 子弹类的构造函数。
     * @param x 子弹的初始横坐标
     * @param y 子弹的初始纵坐标
     */
    public Bullet(int x, int y) {
        this.x = x;      // 设置子弹的横坐标
        this.y = y;      // 设置子弹的纵坐标
        isAlive = true;  // 新创建的子弹默认为存活状态
    }
}

Obstacle类

属性

  • x, y:障碍物左上角的坐标(以像素为单位)。
  • width, height:障碍物的宽度和高度(以像素为单位)。
  • isAlive :布尔类型,标记障碍物是否存活(true 表示存在,false 表示已被子弹击中或移出屏幕)。

构造方法

  • 创建 Obstacle 对象时,需传入障碍物的初始坐标 (x, y) 以及尺寸参数 widthheight
  • 默认 isAlive = true,表示新创建的障碍物初始状态为存活。

设计说明

该类仅负责存储障碍物的数据信息,不包含任何绘图或游戏逻辑。当 isAlive 被设置为 false 时,表示障碍物已被击毁,不再参与绘制和碰撞检测。

java 复制代码
public class Obstacle {
    // 障碍物的横坐标(以像素为单位)
    public int x;
    // 障碍物的纵坐标(以像素为单位)
    public int y;
    // 障碍物的宽度(以像素为单位)
    public int width;
    // 障碍物的高度(以像素为单位)
    public int height;
    // 障碍物是否存活(true表示存在,false表示已被子弹击中或移出屏幕)
    public boolean isAlive;

    /**
     * 障碍物类的构造函数。
     * @param x 障碍物的初始横坐标
     * @param y 障碍物的初始纵坐标
     * @param width 障碍物的宽度
     * @param height 障碍物的高度
     */
    public Obstacle(int x, int y, int width, int height) {
        this.x = x;           // 设置障碍物的横坐标
        this.y = y;           // 设置障碍物的纵坐标
        this.height = height; // 设置障碍物的高度
        this.width = width;   // 设置障碍物的宽度
        isAlive = true;       // 新创建的障碍物默认为存活状态
    }
}

DrawPanel类

新增功能

  • 障碍物动态移动:实现障碍物持续向下移动,并支持无限生成。
  • 游戏难度递增:每当障碍物被击碎后,会生成新的障碍物,并且随着击碎数量的增加,障碍物的移动速度会逐渐加快。
  • 碰撞检测:实现飞机与障碍物之间的矩形碰撞箱检测,作为游戏结束的核心判定条件。

新增私有变量

java 复制代码
// 背景音乐播放器
private Clip bgMusic;

新增方法(加载、开始、结束音乐)

java 复制代码
/**
 * 加载背景音乐文件。
 * 该方法尝试从指定路径读取音频文件,并初始化 Clip 对象以备播放。
 * 如果加载失败,会在控制台打印错误信息。
 */
public void loadMusic() {
    try {
        // 读取桌面上的 wav 文件,用于测试音频本身能否正常播放
        File file = new File("音乐路径");
        AudioInputStream ais = AudioSystem.getAudioInputStream(file);

        bgMusic = AudioSystem.getClip();
        bgMusic.open(ais);
        bgMusic.loop(Clip.LOOP_CONTINUOUSLY);
        bgMusic.stop(); // 初始加载后先停止,等待游戏开始再播放
    } catch (UnsupportedAudioFileException | IOException | LineUnavailableException e) {
        e.printStackTrace();
        // 方便检查错误来源
        System.out.println("音乐加载失败!请检查桌面是否存在 plane.wav 文件。");
    }
}

/**
 * 播放背景音乐,从头开始。
 * 如果音乐播放器已初始化,则将其定位到开头并开始播放。
 */
public void playBgm() {
    if (bgMusic != null) {
        bgMusic.setFramePosition(0); // 跳转到音频开头
        bgMusic.start();
    }
}

/**
 * 停止背景音乐,在游戏结束时调用。
 * 如果音乐播放器正在播放,则停止播放。
 */
public void stopBgm() {
    if (bgMusic != null) {
        bgMusic.stop();
    }
}

在 DrawPanel 构造方法中调用加载音乐

DrawPanel 类的构造方法末尾,添加对 loadMusic() 的调用,以确保游戏启动时背景音乐已被加载。

java 复制代码
loadMusic();

至此,DrawPanel 类中与背景音乐相关的代码已全部添加完毕。接下来,我们继续实现障碍物的移动逻辑和碰撞检测功能。

障碍物移动方法

java 复制代码
/**
 * 控制所有障碍物的移动。
 * 遍历障碍物数组,对每个存活的障碍物,使其纵坐标增加(即向下移动)。
 * 当障碍物移出屏幕底部(y > 800)时,将其重置到屏幕顶部随机位置,实现无限循环生成。
 */
public void obstacleMove() {
    for (int i = 0; i < obsCount; i++) {
        Obstacle ob = obsArray[i];
        if (!ob.isAlive) continue; // 跳过已销毁的障碍物
        ob.y += obstacleSpeed; // 障碍物向下移动

        // 如果障碍物移出屏幕底部,则重置到顶部随机位置
        if (ob.y > 800) {
            ob.y = -20; // 从屏幕顶部稍上方重新出现
            ob.x = random.nextInt(560); // 在水平方向(0~559)随机生成新位置
        }
    }
}

碰撞检测方法

java 复制代码
/**
 * 检测飞机是否与任意存活的障碍物发生碰撞。
 * 采用矩形碰撞箱(AABB)检测算法。
 * @return true 表示发生碰撞,游戏应结束;false 表示未发生碰撞。
 */
public boolean checkPlaneHitObstacle() {
    // 计算飞机(假设为30x30的正方形,中心点为(planeX, planeY))的碰撞箱边界
    int planeLeft = planeX - 15;
    int planeTop = planeY - 15;
    int planeRight = planeX + 15;
    int planeBottom = planeY + 15;

    for (int i = 0; i < obsCount; i++) {
        Obstacle ob = obsArray[i];
        if (!ob.isAlive) {
            continue; // 跳过已销毁的障碍物
        }

        // 计算当前障碍物的碰撞箱边界
        int obLeft = ob.x;
        int obTop = ob.y;
        int obRight = ob.x + ob.width;
        int obBottom = ob.y + ob.height;

        // AABB 碰撞检测:判断两个矩形是否相交
        boolean isHit = !(planeRight <= obLeft || planeLeft >= obRight || planeBottom <= obTop || planeTop >= obBottom);
        if (isHit) {
            return true; // 发生碰撞
        }
    }
    return false; // 未发生碰撞
}

为了增加游戏的趣味性和挑战性,我对障碍物的生成逻辑做了一点小优化:现在障碍物被击碎后重新生成时,其大小会随机化。这样每次出现的障碍物尺寸都会不同,让游戏过程更加多变和有趣。

子弹与障碍物碰撞检测及击碎逻辑

java 复制代码
/**
 * 检测子弹是否击中障碍物,并处理击中后的逻辑(积分、难度递增、障碍物重置)。
 * @param b 待检测的子弹对象
 */
public void checkBulletHitObstacle(Bullet b) {
    for (int j = 0; j < obsCount; j++) {
        Obstacle ob = obsArray[j];
        if (!ob.isAlive) continue; // 跳过已销毁的障碍物

        // 矩形碰撞检测(AABB)
        boolean hit = (b.x < ob.x + ob.width)
                && (b.x + b.width > ob.x)
                && (b.y < ob.y + ob.height)
                && (b.y + b.height > ob.y);

        if (hit) {
            // 击中处理
            ob.isAlive = false; // 障碍物消失(标记为被击毁)
            b.isAlive = false;  // 子弹消失
            score += 10;        // 击碎障碍物,积分 +10
            updateScore();      // 更新积分显示
            destroyCount += 1;  // 击碎计数 +1

            // 每击碎 3 个障碍物,提升一次游戏难度(障碍物移动速度加快)
            if (destroyCount % 3 == 0) {
                obstacleSpeed += 2;
                // 速度上限为 30,防止过快
                if (obstacleSpeed >= 30) {
                    obstacleSpeed = 30;
                }
            }

            // 重置障碍物:重新激活,并随机生成新位置和大小
            ob.isAlive = true;
            ob.y = -50; // 从屏幕顶部稍上方重新出现
            ob.x = random.nextInt(40, 560); // 水平位置在 40~559 之间随机
            int randomSize = random.nextInt(20, 60); // 随机大小(20~59 像素)
            ob.width = randomSize;
            ob.height = randomSize;

            break; // 一颗子弹只能击中一个障碍物
        }
    }
}

完整 DrawPanel 类代码

为了方便大家查阅和运行,这里提供 DrawPanel 类的完整代码。请注意,代码中使用了绝对路径加载图片和音频文件,在实际项目中建议将资源文件放入 src/main/resources 目录,并使用 ClassLoader.getResource() 等相对路径方式加载。

java 复制代码
import javax.sound.sampled.*;
import javax.swing.*;
import java.awt.*;
import java.io.File;
import java.io.IOException;
import java.util.Random;

public class DrawPanel extends JPanel {
    // 游戏核心变量
    public int obstacleSpeed = 20; // 障碍物下落速度
    public int destroyCount = 0;   // 击碎障碍物计数

    public int planeX, planeY;     // 飞机坐标
    public int gameState = 0;      // 游戏状态:0-准备,1-进行中,2-结束

    // 图片资源
    private final Image bgImage;
    private final Image planeImage;
    private final Image themeImage;
    private final Image bulletImage;
    private final Image obstacleImage;

    // 积分与时间
    public int score = 0;
    public int time = 0;
    private JLabel scoreLabel;
    private JLabel timeLabel;

    // 障碍物数组
    Obstacle[] obsArray;
    int obsCount = 8;

    // 子弹数组
    Bullet[] bulletArray;
    int bulletMaxCount = 20;
    int bulletIndex;

    Random random;

    // 背景音乐播放器
    private Clip bgMusic;

    /**
     * 加载背景音乐文件。
     * 注意:这里使用了绝对路径,实际开发中建议将音乐文件放在 resources 目录下。
     */
    public void loadMusic() {
        try {
            // 读取桌面上的 wav 文件(测试用)
            File file = new File("C:\\Users\\Lenovo\\Desktop\\且听风吟 - 轻音乐网.wav");
            AudioInputStream ais = AudioSystem.getAudioInputStream(file);

            bgMusic = AudioSystem.getClip();
            bgMusic.open(ais);
            bgMusic.loop(Clip.LOOP_CONTINUOUSLY);
            bgMusic.stop(); // 初始加载后先停止,等待游戏开始再播放
        } catch (UnsupportedAudioFileException | IOException | LineUnavailableException e) {
            e.printStackTrace();
            System.out.println("音乐加载失败!请检查桌面是否存在指定的音频文件。");
        }
    }

    /**
     * 播放背景音乐,从头开始。
     */
    public void playBgm() {
        if (bgMusic != null) {
            bgMusic.setFramePosition(0); // 跳转到音频开头
            bgMusic.start();
        }
    }

    /**
     * 停止背景音乐,游戏结束时调用。
     */
    public void stopBgm() {
        if (bgMusic != null) {
            bgMusic.stop();
        }
    }

    /**
     * DrawPanel 构造方法:初始化界面组件、资源、监听器等。
     */
    public DrawPanel() {
        // 加载图片资源(注意:这里使用了绝对路径,实际开发中建议使用相对路径)
        bgImage = new ImageIcon("背景图片").getImage();
        planeImage = new ImageIcon("飞机图片").getImage();
        themeImage = new ImageIcon("背景图片").getImage();
        bulletImage = new ImageIcon("子弹").getImage();
        obstacleImage = new ImageIcon("障碍物").getImage();

        this.setLayout(null);

        // 积分标签
        scoreLabel = new JLabel("积分:0");
        scoreLabel.setFont(new Font("黑体", Font.BOLD, 20));
        scoreLabel.setBounds(20, 50, 150, 30);
        this.add(scoreLabel);
        scoreLabel.setVisible(true);

        // 时间标签
        timeLabel = new JLabel("时间:0");
        timeLabel.setFont(new Font("黑体", Font.BOLD, 20));
        timeLabel.setForeground(Color.white);
        timeLabel.setBounds(420, 50, 150, 30);
        this.add(timeLabel);
        timeLabel.setVisible(true);

        // 开始游戏按钮
        JButton gameStart = new JButton("开始游戏");
        gameStart.setBounds(230, 490, 140, 40);
        this.add(gameStart);

        // 初始化随机数生成器
        random = new Random();

        // 初始化障碍物数组
        obsArray = new Obstacle[obsCount];
        for (int i = 0; i < obsCount; i++) {
            int w = 40, h = 40;
            int rx = random.nextInt(50, 560);
            int ry = random.nextInt(70, 640);
            obsArray[i] = new Obstacle(rx, ry, w, h);
        }

        // 初始化子弹数组
        bulletArray = new Bullet[bulletMaxCount];

        // 创建游戏监听器并绑定事件
        GameListener gameListener = new GameListener(this);
        gameStart.addActionListener(gameListener);
        this.addMouseWheelListener(gameListener); // 绑定滚轮监听
        this.addMouseMotionListener(gameListener);

        // 加载背景音乐
        loadMusic();
    }

    /**
     * 控制所有障碍物的移动。
     * 遍历障碍物数组,对每个存活的障碍物,使其纵坐标增加(即向下移动)。
     * 当障碍物移出屏幕底部(y > 800)时,将其重置到屏幕顶部随机位置,实现无限循环生成。
     */
    public void obstacleMove() {
        for (int i = 0; i < obsCount; i++) {
            Obstacle ob = obsArray[i];
            if (!ob.isAlive) continue; // 跳过已销毁的障碍物
            ob.y += obstacleSpeed; // 障碍物向下移动

            // 如果障碍物移出屏幕底部,则重置到顶部随机位置
            if (ob.y > 800) {
                ob.y = -20; // 从屏幕顶部稍上方重新出现
                ob.x = random.nextInt(560); // 在水平方向(0~559)随机生成新位置
            }
        }
    }

    /**
     * 子弹移动与碰撞检测(每帧调用)。
     * 包括子弹移动、边界检测、与障碍物的碰撞检测及击碎后的逻辑处理。
     */
    public void bulletMoveAndCollision() {
        for (int i = 0; i < bulletMaxCount; i++) {
            Bullet b = bulletArray[i];
            if (b == null || !b.isAlive) continue;

            // 子弹向上移动(y 减小)
            b.y -= 30;
            if (b.y < 0) {
                b.isAlive = false; // 飞出上边界,子弹死亡
                continue;
            }

            // 子弹与障碍物碰撞检测
            for (int j = 0; j < obsCount; j++) {
                Obstacle ob = obsArray[j];
                if (!ob.isAlive) continue;

                // 矩形碰撞检测(AABB)
                boolean hit = (b.x < ob.x + ob.width)
                        && (b.x + b.width > ob.x)
                        && (b.y < ob.y + ob.height)
                        && (b.y + b.height > ob.y);

                if (hit) {
                    // 击中处理
                    ob.isAlive = false; // 障碍物消失(标记为被击毁)
                    b.isAlive = false;  // 子弹消失
                    score += 10;        // 击碎障碍物,积分 +10
                    updateScore();
                    destroyCount += 1;  // 击碎计数 +1

                    // 每击碎 3 个障碍物,提升一次游戏难度(障碍物移动速度加快)
                    if (destroyCount % 3 == 0) {
                        obstacleSpeed += 2;
                        // 速度上限为 30,防止过快
                        if (obstacleSpeed >= 30) {
                            obstacleSpeed = 30;
                        }
                    }

                    // 重置障碍物:重新激活,并随机生成新位置和大小
                    ob.isAlive = true;
                    ob.y = -50; // 从屏幕顶部稍上方重新出现
                    ob.x = random.nextInt(40, 560); // 水平位置在 40~559 之间随机
                    int randomSize = random.nextInt(20, 60); // 随机大小(20~59 像素)
                    ob.width = randomSize;
                    ob.height = randomSize;

                    break; // 一颗子弹只能击中一个障碍物
                }
            }
        }
    }

    /**
     * 检测飞机是否与任意存活的障碍物发生碰撞(AABB 碰撞检测)。
     * @return true 表示发生碰撞,游戏应结束;false 表示未发生碰撞。
     */
    public boolean checkPlaneHitObstacle() {
        // 计算飞机(假设为 30x30 的正方形,中心点为 (planeX, planeY))的碰撞箱边界
        int planeLeft = planeX - 15;
        int planeTop = planeY - 15;
        int planeRight = planeX + 15;
        int planeBottom = planeY + 15;

        for (int i = 0; i < obsCount; i++) {
            Obstacle ob = obsArray[i];
            if (!ob.isAlive) {
                continue; // 跳过已销毁的障碍物
            }

            // 计算当前障碍物的碰撞箱边界
            int obLeft = ob.x;
            int obTop = ob.y;
            int obRight = ob.x + ob.width;
            int obBottom = ob.y + ob.height;

            // AABB 碰撞检测:判断两个矩形是否相交
            boolean isHit = !(planeRight <= obLeft || planeLeft >= obRight || planeBottom <= obTop || planeTop >= obBottom);
            if (isHit) {
                return true; // 发生碰撞
            }
        }
        return false; // 未发生碰撞
    }

    /**
     * 重写 paint 方法,绘制游戏画面。
     */
    @Override
    public void paint(Graphics g) {
        super.paintComponent(g);

        // 绘制背景
        if (bgImage != null) {
            g.drawImage(bgImage, 0, 0, 600, 800, null);
        }

        // 游戏进行中(gameState == 1)
        if (gameState == 1 && planeImage != null) {
            g.setColor(Color.white);
            String timeStr = timeLabel.getText();
            g.drawString(timeStr, 20, 50);
            String scoreStr = scoreLabel.getText();
            g.drawString(scoreStr, 420, 50);

            // 绘制存活的障碍物
            g.setColor(Color.BLACK);
            for (int i = 0; i < obsCount; i++) {
                Obstacle ob = obsArray[i];
                if (ob.isAlive) {
                    g.drawImage(obstacleImage, ob.x, ob.y, ob.width, ob.height, null);
                }
            }

            // 绘制子弹
            g.setColor(Color.YELLOW);
            for (int i = 0; i < bulletMaxCount; i++) {
                Bullet b = bulletArray[i];
                if (b != null && b.isAlive) {
                    g.drawImage(bulletImage, b.x, b.y, b.width, b.height, null);
                }
            }

            // 绘制飞机
            g.drawImage(planeImage, planeX - 15, planeY - 15, 30, 30, null);
        }
        // 游戏准备界面(gameState == 0)
        else if (gameState == 0) {
            g.drawImage(themeImage, 0, 0, 600, 800, null);
        }
        // 游戏结束界面(gameState == 2)
        else if (gameState == 2) {
            g.drawImage(bgImage, 0, 0, 600, 800, null);

            // 绘制剩余障碍物
            for (int i = 0; i < obsCount; i++) {
                Obstacle ob = obsArray[i];
                if (ob.isAlive) {
                    g.drawImage(obstacleImage, ob.x, ob.y, ob.width, ob.height, null);
                }
            }

            // 绘制剩余子弹(注意:这里原代码使用了 obstacleImage,可能是笔误,应为 bulletImage)
            for (int i = 0; i < bulletMaxCount; i++) {
                Bullet b = bulletArray[i];
                if (b.isAlive) {
                    g.drawImage(obstacleImage, b.x, b.y, b.width, b.height, null);
                }
            }

            // 绘制飞机
            g.drawImage(planeImage, planeX - 15, planeY - 15, 30, 30, null);

            // 绘制"游戏结束"文字
            g.setColor(Color.red);
            g.setFont(new Font("黑体", Font.BOLD, 45));
            g.drawString("游戏结束!", 180, 350);

            g.setFont(new Font("黑体", Font.BOLD, 22));
            g.drawString("最终分数:" + score, 200, 410);
        }
    }

    /**
     * 更新时间显示。
     */
    public void updateTime() {
        timeLabel.setText("时间:" + time);
    }

    /**
     * 更新积分显示。
     */
    public void updateScore() {
        scoreLabel.setText("积分:" + score);
    }
}

GameListener类

GameListener 类负责处理游戏中的用户交互事件,包括鼠标移动、鼠标滚轮滚动以及按钮点击。它实现了 MouseMotionListenerActionListenerMouseWheelListener 接口,是连接用户操作与游戏逻辑的桥梁。

核心功能

  • 游戏主循环 :通过 Timer 定时器驱动游戏帧更新,实现时间流逝、积分计算、障碍物移动、子弹移动与碰撞检测等核心逻辑。
  • 游戏状态控制:响应【开始游戏】按钮点击,初始化游戏状态(重置分数、时间、障碍物位置等),并启动背景音乐和游戏主循环。
  • 飞机控制:监听鼠标移动,实时更新飞机坐标,实现飞机跟随鼠标移动。
  • 子弹发射:监听鼠标滚轮事件,当玩家滚动滚轮时,从飞机当前位置发射子弹。

完整代码

java 复制代码
import javax.swing.*;
import java.awt.event.*;

public class GameListener implements MouseMotionListener, ActionListener, MouseWheelListener {
    private DrawPanel drawPanel; // 游戏绘图面板引用
    private Timer timer;         // 游戏主循环计时器
    private int timeCount = 0;   // 计时器计数(用于计算游戏时间)

    /**
     * 构造函数,初始化监听器并设置游戏主循环计时器。
     * @param dp 游戏绘图面板实例,用于更新游戏状态和画面
     */
    public GameListener(DrawPanel dp) {
        this.drawPanel = dp;

        // 创建定时器,每 50 毫秒(20 FPS)触发一次游戏逻辑更新
        timer = new Timer(50, new ActionListener() {
            @Override
            public void actionPerformed(ActionEvent e) {
                timeCount++;
                // 将计时器计数转换为秒(每 20 次计数为 1 秒)
                drawPanel.time = (int) (timeCount * 0.05);
                drawPanel.updateTime(); // 更新界面时间显示

                // 游戏开始后,每 10 秒自动增加 1 分(鼓励玩家坚持游戏)
                if (drawPanel.time != 0 && drawPanel.time % 10 == 0) {
                    drawPanel.score += 1;
                    drawPanel.updateScore();
                }

                // 游戏逻辑更新:障碍物移动、子弹移动与碰撞检测
                drawPanel.obstacleMove();
                drawPanel.bulletMoveAndCollision();

                // 碰撞检测:检查飞机是否与障碍物相撞
                if (drawPanel.gameState == 1) {
                    boolean crash = drawPanel.checkPlaneHitObstacle();
                    if (crash) {
                        drawPanel.gameState = 2; // 游戏状态切换为"结束"
                        timer.stop();            // 停止游戏主循环
                        drawPanel.stopBgm();     // 停止背景音乐
                    }
                }

                drawPanel.repaint(); // 重绘画面,刷新游戏界面
            }
        });
    }

    /**
     * 处理按钮点击事件(目前仅处理【开始游戏】按钮)。
     * 点击后初始化游戏状态、重置障碍物、播放背景音乐并启动游戏主循环。
     */
    @Override
    public void actionPerformed(ActionEvent e) {
        String ac = e.getActionCommand();
        JButton btn = (JButton) e.getSource();
        if (ac.equals("开始游戏")) {
            // 重置游戏状态
            drawPanel.gameState = 1;        // 切换为"游戏进行中"
            drawPanel.time = 0;             // 时间归零
            drawPanel.updateTime();
            drawPanel.score = 0;            // 积分归零
            drawPanel.updateScore();
            drawPanel.obstacleSpeed = 20;   // 重置障碍物移动速度
            drawPanel.destroyCount = 0;     // 重置击碎计数

            // 重置所有障碍物状态和位置
            for (int i = 0; i < drawPanel.obsCount; i++) {
                Obstacle ob = drawPanel.obsArray[i];
                ob.isAlive = true; // 重新激活障碍物
                ob.x = drawPanel.random.nextInt(560); // 随机水平位置(0~559)
                ob.y = drawPanel.random.nextInt(640); // 随机垂直位置(0~639)
            }

            drawPanel.playBgm(); // 开始播放背景音乐
            timer.start();       // 启动游戏主循环计时器

            btn.setVisible(false); // 隐藏【开始游戏】按钮
        }
    }

    /**
     * 鼠标拖动事件(本游戏中未使用,但接口要求实现)。
     */
    @Override
    public void mouseDragged(MouseEvent e) {
        // 当前游戏未使用鼠标拖动功能,留空即可
    }

    /**
     * 鼠标移动事件:实时更新飞机坐标,实现飞机跟随鼠标移动。
     * 仅当游戏处于进行中状态(gameState == 1)时生效。
     */
    @Override
    public void mouseMoved(MouseEvent e) {
        if (drawPanel.gameState == 1) {
            drawPanel.planeX = e.getX(); // 更新飞机横坐标
            drawPanel.planeY = e.getY(); // 更新飞机纵坐标
            drawPanel.repaint();         // 立即重绘,让飞机跟随鼠标移动
        }
    }

    /**
     * 鼠标滚轮事件:滚动滚轮时发射子弹。
     * 子弹从飞机中心位置向上发射,采用循环复用子弹数组的方式管理子弹对象。
     */
    @Override
    public void mouseWheelMoved(MouseWheelEvent e) {
        // 仅当游戏进行中时才响应滚轮事件
        if (drawPanel.gameState != 1) return;

        // 循环复用子弹数组位置(避免频繁创建新对象)
        drawPanel.bulletIndex = (drawPanel.bulletIndex + 1) % drawPanel.bulletMaxCount;

        // 在飞机中心位置(planeX, planeY)创建新子弹,并向上发射
        // 子弹出生点微调:x 方向向左偏移 4 像素,y 方向向上偏移 20 像素,使其从飞机头部射出
        drawPanel.bulletArray[drawPanel.bulletIndex] = new Bullet(drawPanel.planeX - 4, drawPanel.planeY - 20);
    }
}

结束

到这里,我们就完成了游戏背景音乐的接入。这次开发过程踩了不少资源加载的坑,也让我对 IDEA 的resources资源目录、getResourceAsStream读取机制有了更加实际的理解。很多问题看教程觉得很简单,真正动手写代码的时候才会遇到各种空指针异常。

写Swing小游戏的乐趣就在于不断完善功能,从窗体、鼠标交互,到计时器计分,再到音频音效,一点点把纸上的想法变成可以运行的程序。

后续我会继续完善障碍物生成、碰撞检测、游戏结束逻辑,继续把这个飞机躲避障碍物游戏补全。如果文章对你有帮助,欢迎点赞收藏,有问题可以在评论区留言交流。

相关推荐
阿沐沐,1 小时前
PowerShell 里改了 config.toml,Codex CLI 仍用旧值:先分清该改哪一层
java·服务器·数据库·人工智能·ai·ai编程
YOLO数据集集合1 小时前
火情监测新方案:无人机红外-可见光多模态火点烟雾数据集(含YOLOv11实战)
人工智能·yolo·计算机外设·无人机·无人机数据集·可见光多模态
杨丰玮4181 小时前
从零手写Java飞机躲障碍游戏|Swing绘图、鼠标跟随、计时器碰撞检测实战(二)
java·游戏·计算机外设
Rain的Java大神之路1 小时前
如何避免订单重复提交
java·redis·后端·面试·架构·rabbitmq·rocketmq
ps酷教程2 小时前
解析自定义数据
java
wuminyu2 小时前
JDK21中FFM api的upcall回调机制解析
java·linux·c语言·jvm·c++
桦说编程2 小时前
深入理解 FutureTask 状态机——从契约到实现
java·后端·性能优化
松仔log2 小时前
Kotlin中级——协程
java·jvm·kotlin