本文基于 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)目录下,以及它如何被编译输出到out或target/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 类专注于游戏界面的初始化和窗口管理,不涉及具体的游戏逻辑(如碰撞检测、分数计算等),这些逻辑由其他类(如 DrawPanel、GameListener)负责。
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)以及尺寸参数width和height。 - 默认
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 类负责处理游戏中的用户交互事件,包括鼠标移动、鼠标滚轮滚动以及按钮点击。它实现了 MouseMotionListener、ActionListener 和 MouseWheelListener 接口,是连接用户操作与游戏逻辑的桥梁。
核心功能
- 游戏主循环 :通过
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小游戏的乐趣就在于不断完善功能,从窗体、鼠标交互,到计时器计分,再到音频音效,一点点把纸上的想法变成可以运行的程序。
后续我会继续完善障碍物生成、碰撞检测、游戏结束逻辑,继续把这个飞机躲避障碍物游戏补全。如果文章对你有帮助,欢迎点赞收藏,有问题可以在评论区留言交流。