欢迎光临
我们一直在努力

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

本篇基于 Java Swing 从零开发飞机躲避障碍物小游戏,主要修复上一版本遗留的各类 bug,完善护盾、清屏、计时、延时生成障碍物功能,记录开发过程遇到的各类真实坑点。适合 Swing 图形界面初学者阅读。

上期回顾
在上一篇博文中,我们搭建飞机躲避障碍物游戏的基础框架,完成窗体与绘图面板搭建,实现鼠标移动控制飞机位置,使用鼠标滚轮完成子弹发射;完成基础的障碍物逻辑,封装Obstacle、Bullet实体类,实现子弹与障碍物的基础碰撞。

本章功能实现
本篇我们将进一步完善飞机躲避障碍物游戏,实现以下核心功能:

  • 障碍物动态移动:实现障碍物持续向下移动,增加游戏挑战性。
  • 新增道具系统:引入三种功能道具——护盾、霰弹(火力增强)和清屏。
  • 护盾伤害机制:
    • 护盾可为飞机抵挡一次障碍物撞击。
    • 撞击后护盾自动消失。
    • 若未装备护盾时撞击障碍物,则游戏直接结束。
  • 清屏道具逻辑优化:
    • 拾取清屏道具后,屏幕内所有障碍物立即消失。
    • 障碍物滑出屏幕底部后会自动复活并重置位置,确保游戏过程中障碍物持续生成,不会中断。
  • 精准游戏计时系统:
    • 采用系统时间戳替代帧计数进行游戏计时。
    • 从根本上解决因丢帧导致的计时不准问题。
    • 实时更新游戏界面中的时间显示标签。
  • 代码重构与优化:
    • 复用已封装好的飞机-障碍物碰撞检测方法,消除代码冗余。
    • 遵循面向对象封装思想,提升代码可维护性。
  • 道具系统完整闭环:
    • 确保护盾、清屏、火力增强三种道具均能正常生效。
    • 火力增强效果持续5秒,期间子弹呈现散射效果。
    • 道具移出屏幕边界后自动消失,避免内存无效占用。
  • 以下为已处理的道具图片,大家要用自行取用~
    请添加图片描述请添加图片描述

    请添加图片描述
    本期使用的类有:
    Main(程序入口),GameListener(监听器),DrawPanel(绘制),Obstacles(障碍物),Bullet(子弹),Item(道具)

    1.Main类的程序逻辑

    功能:设计窗体来承载游戏,有窗体的大小,标题等;同时设置窗体的性质:是否允许拖拽来改变大小,关闭窗体的形式等;同时为窗体添加上各种组件:监听器,绘图面板等
    ·创建showUI方法,用于实现上述功能:

    public void showUI(){
    //基本显示
    //导入JFrame对于的包
    JFrame jf=new JFrame("飞行器游戏");
    //设置窗体大小
    jf.setSize(600,800);
    //设置窗体大小为“不可更改”
    jf.setResizable(false);
    //窗体居中显示
    jf.setLocationRelativeTo(null);
    //设置窗体关闭方式
    jf.setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE);
    //设置绝对化布局
    jf.setLayout(null);
    //将我们DrawPanel类初始化
    DrawPanel drawPanel=new DrawPanel();
    //设置JPanel的大小(覆盖整个页面)
    drawPanel.setBounds(0,0,600,800);
    //将DrawPanel添加到窗体上
    jf.add(drawPanel);
    //将我们的监听器类(GameListener)初始化
    GameListener gameListener=new GameListener(drawPanel);
    //将监听器添加到窗体上
    jf.addMouseMotionListener(gameListener);
    //设置窗体可见
    jf.setVisible(true);
    }
    }

    ·建立程序入口,调用showUI方法

    static void main() {
    Main main=new Main();
    main.showUI();
    }

    此时,我们运行起来是这样的:
    在这里插入图片描述

    2.设置Obstacles类与Bullet类

    先来看一个较简单的Bullet类:

    ·设置变量

    public int x,y,speedX;
    public int width=8,height=16;
    public boolean isAlive;

    这个speedX是我新增的变量,用于控制子弹在水平方向的移动速度
    比如说:
    当speedX=0:子弹水平方向不偏移,竖直向上飞
    当speedX=-6:x方向不断减少,子弹向左上方飞行
    先说一下,这个与我们后来的霰弹功能有关

    ·创建Bullet类,设置变量数据的传入

    public Bullet(int x, int y, int speedX){
    this.x=x;
    this.y=y;
    this.speedX=speedX;
    isAlive=true;
    }

    ·创建子弹移动(move)的方法

    public void move(){
    x += speedX;
    y -= 12;
    }
    }

    子弹图片:
    请添加图片描述

    接下来是 Obstacle 类的核心逻辑

    Obstacle 类定义了游戏中障碍物的基本属性和行为。以下是其核心代码实现:
    设置变量
    障碍物的坐标(x,y)与障碍物的大小,状态(已击碎/未击碎)

    public int x,y;
    public int width,height;
    public boolean isAlive;

    构造函数:初始化障碍物的位置、大小,并默认将其状态设为“存活”(isAlive=true)。

    public Obstacle(int x, int y, int width, int height){
    this.x=x;
    this.y=y;
    this.width=width;
    this.height=height;
    isAlive=true;
    }

    move方法:根据传入的 speed 参数,使障碍物沿 Y 轴(垂直方向)向下移动,实现持续下落的效果。

    public void move(int speed){
    y += speed;
    }

    isOutOfScreen方法:判断障碍物是否已移出屏幕底部(Y坐标大于800),为后续的“复活重置”逻辑提供条件。

    public boolean isOutOfScreen(){
    return y > 800;
    }

    reset方法:这是本期实现“障碍物源源不断生成”的关键。当障碍物移出屏幕后,此方法会将其重置到屏幕顶部上方(y = -40),并赋予一个新的 X 坐标,然后重新激活。这避免了频繁创建新对象,优化了性能。

    //障碍物重置复活,飞到屏幕顶端外面,给新x坐标
    public void reset(int newX){
    this.y = –40;
    this.x = newX;
    this.isAlive = true;
    }

    障碍物图片:
    请添加图片描述

    3. Item 道具类代码逻辑

    Item 类负责管理游戏中的三种道具:护盾、清屏和火力增强。它定义了道具的属性、移动、绘制以及碰撞检测等核心行为。
    成员变量:
    * x, y: 道具在屏幕上的坐标。
    * width, height: 道具的尺寸,默认均为 32 像素。
    * type: 道具类型(1-护盾,2-清屏,3-火力增强),用于区分不同功能。
    * isAlive: 道具是否处于活动状态(是否可被拾取)。

    public class Item {
    public int x;
    public int y;
    public int width=32;
    public int height=32;
    // 1护盾 2清屏 3火力加强
    public int type;
    public boolean isAlive;

    构造函数Item(int x, int y, int type):
    初始化道具的位置、类型,并默认将其状态设为“存活”(isAlive=true)。

    public Item(int x,int y,int type){
    this.x=x;
    this.y=y;
    this.type=type;
    isAlive=true;
    }

    ** move()方法 **:
    每帧调用,使道具以固定速度(y += 4)向下移动,模拟从屏幕上方掉落的效果。

    //道具向下移动
    public void move(){
    y +=4;
    }

    isOutOfScreen()方法:
    判断道具是否已移出屏幕底部(Y坐标大于800)。若移出,则应在逻辑层将其标记为失效或移除,以优化性能。

    //判断飞出屏幕底部
    public boolean isOutOfScreen(){
    return y>800;
    }

    draw(Graphics g, Image shieldImg, Image clearImg, Image fireImg)方法:
    根据 type 的值,使用 switch 语句选择对应的图片进行绘制。
    仅在道具存活(isAlive 为 true)时执行绘制,避免绘制无效道具。

    //绘制道具,接收三张道具图片
    public void draw(Graphics g,Image shieldImg,Image clearImg,Image fireImg){
    if(!isAlive) return;
    switch (type){
    case 1:
    g.drawImage(shieldImg,x,y,width,height,null);
    break;
    case 2:
    g.drawImage(clearImg,x,y,width,height,null);
    break;
    case 3:
    g.drawImage(fireImg,x,y,width,height,null);
    break;
    }
    }

    checkHit(int planeX, int planeY, int planeW, int planeH)方法:
    检测道具与飞机(用一个矩形区域表示)是否发生碰撞。
    采用轴对齐包围盒(AABB) 算法进行矩形碰撞检测,逻辑清晰高效。
    同样,只在道具存活时进行检测,避免与已失效的道具发生交互。

    //检测飞机和道具碰撞
    public boolean checkHit(int planeX,int planeY,int planeW,int planeH){
    if(!isAlive) return false;
    boolean hit = (planeX < x+width)
    &&(planeX+planeW >x)
    &&(planeY < y+height)
    &&(planeY+planeH>y);
    return hit;
    }
    }

    设计要点:

  • 类型驱动:通过 type 字段区分道具,使 draw 方法能动态切换显示,逻辑与表现分离。
  • 状态管理:isAlive 字段有效管理了道具的生命周期,避免对无效对象进行不必要的计算和绘制。
  • 通用碰撞检测:checkHit 方法参数化,可复用检测飞机与任何矩形物体的碰撞。
  • 4. DrawPanel 类的代码逻辑

    DrawPanel 类是游戏的核心绘制与逻辑控制类,继承自 JPanel,负责管理游戏状态、绘制所有游戏元素、处理碰撞检测以及道具系统等核心功能。

    import javax.sound.sampled.*;
    import javax.swing.*;
    import java.awt.*;
    import java.io.File;
    import java.io.IOException;
    import java.util.ArrayList;
    import java.util.Random;

    public class DrawPanel extends JPanel{
    // 游戏核心变量
    public int obstacleSpeed=20; // 障碍物移动速度
    public int destroyCount=0; // 摧毁障碍物计数,用于难度递增

    // 飞机相关变量
    public int planeX,planeY; // 飞机坐标
    public int planeW=30,planeH=30; // 飞机尺寸
    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; // 障碍物图片

    // 游戏数据与UI组件
    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; // 背景音乐

    //=============道具相关新增变量================
    public ArrayList<Item> itemList=new ArrayList<>(); // 道具列表

    public boolean hasShield=false; // 是否拥有护盾
    public boolean fireBoost=false; // 是否处于火力增强状态
    public long fireBoostEndTime=0; // 火力增强结束时间

    public long lastItemSpawnTime=0; // 上次生成道具的时间
    public long lastClearItemSpawnTime=0; // 上次生成清屏道具的时间

    private Image shieldImg; // 护盾道具图片
    private Image clearImg; // 清屏道具图片
    private Image fireImg; // 火力增强道具图片

    public long LastItemSpawnTime=0; // 上次生成道具的时间(备用变量)

    加载背景音乐
    从指定路径加载WAV音频文件并准备播放

    public void loadMusic(){
    try {
    File file = new File("且听风吟 – 轻音乐网.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("音乐加载失败!检查桌面wav文件");
    }
    }

    播放背景音乐
    从开头开始播放,如果音乐对象存在

    public void playBgm(){
    if(bgMusic!=null){
    bgMusic.setFramePosition(0); // 从头开始
    bgMusic.start();
    }
    }

    停止背景音乐

    public void stopBgm(){
    if(bgMusic!=null){
    bgMusic.stop();
    }
    }

    DrawPanel构造函数 – 初始化游戏面板
    设置初始参数、加载资源、创建UI组件

    public DrawPanel(){
    //初始化飞机起始位置,避免开局飞到左上角
    planeX=250;
    planeY=600;

    // 加载所有游戏图片资源
    bgImage=new ImageIcon("background.jpg").getImage();
    planeImage=new ImageIcon("plane.png").getImage();
    themeImage=new ImageIcon("theme.jpg").getImage();
    bulletImage=new ImageIcon("bullet.png").getImage();
    obstacleImage=new ImageIcon("obstacle.png").getImage();

    // 加载道具图片
    try {
    shieldImg = new ImageIcon("protect.png").getImage();
    clearImg = new ImageIcon("sweep.png").getImage();
    fireImg = new ImageIcon("fire.png").getImage();
    }catch (Exception e){
    System.out.println("道具图片加载失败,请检查桌面图片文件名");
    e.printStackTrace();
    }

    this.setLayout(null); // 使用绝对布局

    // 创建并配置积分标签
    scoreLabel=new JLabel("积分:0");
    scoreLabel.setFont(new Font("黑体",Font.BOLD,20));
    scoreLabel.setBounds(20,50,150,30);
    scoreLabel.setForeground(Color.WHITE);
    this.add(scoreLabel);

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

    // 创建开始游戏按钮
    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,rx=random.nextInt(50,560),ry= random.nextInt(70,640);
    obsArray[i]=new Obstacle(rx,ry,w,h);
    obsArray[i].isAlive = false; // 初始状态为死亡
    }

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

    // 创建游戏监听器并注册事件
    GameListener gameListener=new GameListener(this);
    gameStart.addActionListener(gameListener); // 按钮点击事件
    this.addMouseWheelListener(gameListener); // 鼠标滚轮事件(发射子弹)
    this.addMouseMotionListener(gameListener); // 鼠标移动事件(控制飞机)
    loadMusic(); // 加载背景音乐
    }

    障碍物移动逻辑
    处理所有障碍物的移动和复活机制

    public void obstacleMove(){
    for (int i=0;i<obsCount;i++){
    Obstacle ob=obsArray[i];
    if(!ob.isAlive){
    //已经死亡,跳过移动;等它滑出屏幕底部就复活
    ob.move(obstacleSpeed);
    if(ob.isOutOfScreen()){
    //飞出屏幕,直接复活出新障碍物
    ob.reset(random.nextInt(520));
    }
    continue;
    }

    ob.move(obstacleSpeed);
    if (ob.isOutOfScreen()){
    ob.reset(random.nextInt(520));
    }
    }
    }

    子弹移动与碰撞检测
    处理子弹移动、边界检测、与障碍物的碰撞

    public void bulletMoveAndCollision(){
    for(int i=0;i<bulletMaxCount;i++){
    Bullet b=bulletArray[i];
    if(b==null || !b.isAlive) continue; // 跳过无效子弹

    b.move(); // 调慢子弹速度
    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; // 增加分数
    updateScore(); // 更新分数显示
    destroyCount+=1; // 增加摧毁计数

    // 每摧毁3个障碍物,增加障碍物速度(难度递增)
    if (destroyCount%3==0){
    obstacleSpeed+=2;
    if (obstacleSpeed>=30){
    obstacleSpeed=30; // 速度上限30
    }
    }
    // 重置障碍物位置和大小
    ob.y=–50;
    ob.x= random.nextInt(40,560);
    int randomSize=random.nextInt(20,60);
    ob.width= randomSize;
    ob.height=randomSize;
    //删掉 ob.isAlive=true; 不会立刻复活障碍物
    break; // 一颗子弹只能击中一个障碍物
    }
    }
    }
    }

    检测飞机与障碍物的碰撞
    return true表示发生碰撞,false表示未碰撞

    public boolean checkPlaneHitObstacle(){
    // 计算飞机的碰撞矩形(以飞机中心为原点,±15像素)
    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){
    if(hasShield){
    hasShield=false; // 消耗护盾
    }else{
    gameState=2; // 游戏结束
    }
    return true; // 发生碰撞
    }
    }
    return false; // 未发生碰撞
    }

    重写绘制方法
    根据游戏状态绘制不同的界面

    @Override
    protected void paintComponent(Graphics g) {
    super.paintComponent(g);

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

    //绘制道具
    for(Item item : itemList){
    item.draw(g,shieldImg,clearImg,fireImg);
    }

    //绘制护盾光圈(如果有护盾)
    if(hasShield){
    g.setColor(Color.CYAN);
    g.drawOval(planeX–30,planeY–30,60,60);
    }

    // 根据游戏状态绘制不同界面
    if (gameState==1&&planeImage!=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);
    }
    }
    //绘制子弹
    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);

    } else if (gameState==0) {
    // 开始界面
    if(themeImage!=null){
    g.drawImage(themeImage,0,0,getWidth(),getHeight(),null);
    }

    } else if (gameState==2) {
    // 游戏结束界面
    g.drawImage(bgImage,0,0,getWidth(),getHeight(),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);
    }
    }
    // 绘制所有子弹
    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,planeY,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);
    }
    }

    代码解析:

    • 核心成员变量:

      • gameState:游戏状态(0-开始界面,1-游戏中,2-游戏结束)。
      • planeX, planeY:飞机坐标,通过鼠标监听器实时更新。
      • obsArray:障碍物数组,存储所有障碍物实例。
      • bulletArray:子弹数组,管理所有子弹实例。
      • itemList:道具列表,使用 ArrayList 动态管理道具。
      • hasShield, fireBoost:道具状态标志,分别表示是否拥有护盾和火力增强效果。
      • scoreLabel, timeLabel:积分和时间显示标签。
    • 构造函数 DrawPanel():

      • 初始化飞机起始位置,避免开局时飞机出现在左上角。
      • 加载所有游戏图片资源(背景、飞机、主题图、子弹、障碍物、道具图片)。
      • 设置面板布局为 null(绝对布局),便于精确控制组件位置。
      • 创建并配置积分、时间标签和开始游戏按钮。
      • 初始化障碍物数组,所有障碍物初始状态为“死亡”,游戏开始后再激活。
      • 初始化子弹数组。
      • 创建 GameListener 监听器实例,并注册到按钮和面板上。
      • 调用 loadMusic() 加载背景音乐。
    • obstacleMove() 方法:

      • 遍历所有障碍物,根据其状态执行移动逻辑。
      • 对于已死亡的障碍物,仍会移动,当其移出屏幕底部时调用 reset() 方法复活。
      • 对于存活的障碍物,正常移动并在移出屏幕时复活。
      • 实现了障碍物的循环生成机制,避免频繁创建新对象。
    • bulletMoveAndCollision() 方法:

      • 遍历所有子弹,处理移动和碰撞检测。
      • 子弹移出屏幕顶部时标记为失效。
      • 使用轴对齐包围盒(AABB) 算法检测子弹与障碍物的碰撞。
      • 碰撞后更新积分、增加摧毁计数,并根据摧毁数每3个增加一次障碍物移动速度(上限30)。
      • 碰撞后重置障碍物位置和大小,但不立即激活,等待其自然复活。
    • checkPlaneHitObstacle() 方法:

      • 检测飞机与障碍物的碰撞。
      • 同样使用 AABB 算法,但采用反向逻辑判断(判断是否不碰撞)。
      • 如果发生碰撞且飞机有护盾,则消耗护盾;否则游戏结束(gameState=2)。
    • paintComponent(Graphics g) 方法:

      • 游戏核心绘制方法,根据 gameState 绘制不同界面。
      • 游戏进行中(gameState==1):绘制背景、道具、护盾光圈、存活的障碍物、存活的子弹和飞机。
      • 开始界面(gameState==0):绘制主题背景图。
      • 游戏结束界面(gameState==2):绘制背景、所有障碍物、子弹、飞机,并显示“游戏结束”和最终分数。
    • 辅助方法:

      • updateTime(), updateScore():更新界面上的时间和积分显示。
      • loadMusic(), playBgm(), stopBgm():背景音乐的加载、播放和停止控制。

    设计要点:

  • 状态驱动绘制:通过 gameState 变量控制三种不同界面的绘制逻辑,代码结构清晰。
  • 对象池优化:障碍物和子弹使用数组预创建,通过 isAlive 状态管理生命周期,避免频繁的垃圾回收。
  • 碰撞检测复用:子弹与障碍物、飞机与障碍物的碰撞检测都采用 AABB 算法,但实现方式略有不同,体现了算法的灵活性。
  • 道具系统集成:通过 itemList 管理动态生成的道具,hasShield 和 fireBoost 状态标志实现道具效果。
  • 资源管理:所有图片和音乐资源在构造函数中集中加载,便于管理和错误处理。
  • 界面与逻辑分离:绘制逻辑集中在 paintComponent 中,游戏状态更新和碰撞检测在独立的方法中,符合 MVC 设计思想。
  • 5. GameListener 类代码实现

    GameListener 类实现了游戏的核心事件监听与逻辑驱动,它集成了 MouseMotionListener、ActionListener 和 MouseWheelListener 接口,负责处理鼠标移动、按钮点击和滚轮事件,并驱动游戏主循环。

    public class GameListener implements MouseMotionListener, ActionListener, MouseWheelListener {
    private DrawPanel drawPanel; // 游戏绘制面板引用
    private Timer timer; // 游戏主循环计时器

    /**
    * 构造函数:初始化监听器并设置游戏主循环
    * @param dp 游戏绘制面板实例
    */

    public GameListener(DrawPanel dp){
    this.drawPanel = dp;
    // 创建游戏主循环计时器,每40毫秒(约25帧/秒)触发一次
    timer = new Timer(40, new ActionListener() {
    private long gameStartTime = 0; // 记录游戏开始的时刻

    @Override
    public void actionPerformed(ActionEvent e) {
    // 仅在游戏进行中(state=1)执行逻辑
    if(drawPanel.gameState != 1){
    return;
    }

    // 初始化游戏开始时间(仅第一次进入循环时记录)
    if(gameStartTime == 0){
    gameStartTime = System.currentTimeMillis();
    }

    // 计算已游戏时长(秒)
    long second = (System.currentTimeMillis() – gameStartTime) / 1000;
    drawPanel.time = (int) second;
    drawPanel.updateTime(); // 更新界面时间显示

    // 执行障碍物移动与子弹碰撞逻辑
    drawPanel.obstacleMove();
    drawPanel.bulletMoveAndCollision();

    // ============ 道具生成与处理逻辑 ============
    long now = System.currentTimeMillis();
    // 每10秒生成一个新道具
    if(now – drawPanel.lastItemSpawnTime > 10000){
    int randomType = 1 + drawPanel.random.nextInt(3); // 随机1~3
    int rx = drawPanel.random.nextInt(520); // 随机X坐标
    drawPanel.itemList.add(new Item(rx, –30, randomType));
    drawPanel.lastItemSpawnTime = now;
    }

    // 遍历所有道具,处理移动、边界检测与碰撞
    for(Item item : drawPanel.itemList){
    if(!item.isAlive) continue;

    item.move(); // 道具下落
    if(item.isOutOfScreen()){
    item.isAlive = false; // 移出屏幕则标记为失效
    }

    // 检测道具与飞机碰撞
    if(item.checkHit(drawPanel.planeX, drawPanel.planeY, drawPanel.planeW, drawPanel.planeH)){
    item.isAlive = false; // 拾取后道具消失
    if(item.type == 1){
    drawPanel.hasShield = true; // 护盾道具
    } else if(item.type == 2){
    // 清屏道具:将所有障碍物标记为死亡
    for(int k = 0; k < drawPanel.obsCount; k++){
    drawPanel.obsArray[k].isAlive = false;
    }
    } else if(item.type == 3){
    // 火力增强道具:激活5秒散射效果
    drawPanel.fireBoost = true;
    drawPanel.fireBoostEndTime = System.currentTimeMillis() + 5000;
    }
    }
    }

    // 检查火力增强效果是否到期
    if(drawPanel.fireBoost && System.currentTimeMillis() > drawPanel.fireBoostEndTime){
    drawPanel.fireBoost = false;
    }

    // ========= 飞机与障碍物碰撞检测 =========
    boolean isHit = false;
    for(int i = 0; i < drawPanel.obsCount; i++){
    Obstacle ob = drawPanel.obsArray[i];
    if(!ob.isAlive) continue;

    // 矩形碰撞检测(AABB算法)
    boolean collide =
    drawPanel.planeX < ob.x + ob.width
    && drawPanel.planeX + drawPanel.planeW > ob.x
    && drawPanel.planeY < ob.y + ob.height
    && drawPanel.planeY + drawPanel.planeH > ob.y;

    if(collide){
    isHit = true;
    break;
    }
    }

    // 处理碰撞结果
    if(isHit){
    if(drawPanel.hasShield){
    // ✅ 有护盾:消耗护盾,游戏继续
    drawPanel.hasShield = false;
    } else {
    // ❌ 无护盾:游戏结束
    drawPanel.gameState = 2;
    timer.stop(); // 停止游戏计时器
    drawPanel.stopBgm(); // 停止背景音乐
    }
    }

    drawPanel.repaint(); // 触发界面重绘
    }
    });
    }

    /**
    * 处理按钮点击事件(开始游戏)
    */

    @Override
    public void actionPerformed(ActionEvent e) {
    String ac = e.getActionCommand();
    if("开始游戏".equals(ac)){
    drawPanel.gameState = 1; // 进入游戏状态
    // 隐藏所有按钮(开始游戏按钮)
    for(var comp : drawPanel.getComponents()){
    if(comp instanceof JButton){
    comp.setVisible(false);
    }
    }
    // 激活所有障碍物
    for(int i = 0; i < drawPanel.obsCount; i++){
    drawPanel.obsArray[i].isAlive = true;
    }
    timer.start(); // 启动游戏主循环
    drawPanel.loadMusic(); // 加载背景音乐
    drawPanel.playBgm(); // 播放背景音乐
    }
    }

    /**
    * 鼠标滚轮事件:发射子弹
    * 根据火力增强状态发射单发或三发散射子弹
    */

    @Override
    public void mouseWheelMoved(MouseWheelEvent e) {
    // 仅游戏进行中响应
    if(drawPanel.gameState != 1){
    return;
    }

    // 寻找子弹数组中的空位
    int pos = –1;
    for(int i = 0; i < drawPanel.bulletMaxCount; i++){
    if(drawPanel.bulletArray[i] == null || !drawPanel.bulletArray[i].isAlive){
    pos = i;
    break;
    }
    }
    if(pos == –1) return; // 子弹数组已满,不生成新子弹

    // 计算子弹发射起点(飞机中心上方)
    int px = drawPanel.planeX + drawPanel.planeW / 2;
    int py = drawPanel.planeY;

    if(!drawPanel.fireBoost){
    // 普通状态:发射单发垂直子弹
    drawPanel.bulletArray[pos] = new Bullet(px, py, 0);
    } else {
    // 火力增强状态:发射三发散射子弹(左、中、右)
    drawPanel.bulletArray[pos] = new Bullet(px, py, –6); // 向左偏移

    // 寻找第二个空位
    int pos2 = –1;
    for(int i = 0; i < drawPanel.bulletMaxCount; i++){
    if(drawPanel.bulletArray[i] == null || !drawPanel.bulletArray[i].isAlive){
    pos2 = i;
    break;
    }
    }
    if(pos2 != –1){
    drawPanel.bulletArray[pos2] = new Bullet(px, py, 0); // 垂直
    }

    // 寻找第三个空位
    int pos3 = –1;
    for(int i = 0; i < drawPanel.bulletMaxCount; i++){
    if(drawPanel.bulletArray[i] == null || !drawPanel.bulletArray[i].isAlive){
    pos3 = i;
    break;
    }
    }
    if(pos3 != –1){
    drawPanel.bulletArray[pos3] = new Bullet(px, py, 6); // 向右偏移
    }
    }
    }

    /**
    * 鼠标移动事件:控制飞机位置
    * 将鼠标坐标转换为飞机中心坐标
    */

    @Override
    public void mouseMoved(MouseEvent e) {
    if(drawPanel.gameState != 1){
    return;
    }
    drawPanel.planeX = e.getX() – drawPanel.planeW / 2;
    drawPanel.planeY = e.getY() – drawPanel.planeH / 2;
    }

    /**
    * 鼠标拖拽事件:复用鼠标移动逻辑
    */

    @Override
    public void mouseDragged(MouseEvent e) {
    mouseMoved(e);
    }
    }

    GameListener 类总结

    GameListener 作为游戏的事件驱动与逻辑调度中心,承担了以下核心职责:

  • 游戏主循环驱动:通过 Timer 每 40 毫秒触发一次 actionPerformed,实现稳定的游戏帧更新(约 25 FPS)。循环内集成了时间计算、障碍物移动、子弹碰撞、道具生成与拾取、飞机碰撞检测等所有核心游戏逻辑。
  • 事件监听与响应:
    • 按钮事件:响应“开始游戏”按钮点击,切换游戏状态、激活障碍物、启动计时器与背景音乐。
    • 鼠标移动:实时将鼠标坐标映射为飞机中心位置,实现平滑的飞机控制。
    • 鼠标滚轮:作为子弹发射触发器,根据火力增强状态发射单发或三发散射子弹。
  • 道具系统管理:
    • 定时生成:每 10 秒在屏幕顶部随机位置生成一种随机类型的道具(护盾、清屏、火力增强)。
    • 效果触发:检测道具与飞机的碰撞,并触发对应效果(激活护盾、清除所有障碍物、开启5秒散射火力)。
    • 状态维护:管理 fireBoost 的持续时间,到期后自动关闭效果。
  • 碰撞检测与游戏状态管理:
    • 使用 AABB 算法检测飞机与障碍物的碰撞。
    • 根据碰撞结果和护盾状态决定游戏是否结束(有护盾则消耗,无护盾则游戏结束)。
    • 在游戏结束时停止计时器和背景音乐。
  • 资源与状态同步:
    • 维护游戏时间(基于系统时间戳,避免帧率波动影响计时)。
    • 调用 drawPanel.repaint() 驱动界面重绘,确保视觉状态与逻辑状态同步。
  • 设计要点:

    • 职责清晰:将用户输入、游戏逻辑更新、状态判断集中在一个类中,符合“监听器”模式的初衷。
    • 帧驱动更新:所有动态元素(障碍物、子弹、道具)的移动和碰撞检测都在 Timer 的 actionPerformed 中统一处理,保证了逻辑执行的时序一致性。
    • 状态驱动:严格依赖 drawPanel.gameState 控制逻辑执行范围(如只在游戏进行中响应鼠标滚轮、更新游戏逻辑)。
    • 资源管理:在游戏结束时正确停止 Timer 和背景音乐,避免资源泄漏。

    通过 GameListener,我们将离散的用户操作和持续的游戏逻辑更新整合为一个连贯、响应迅速的游戏体验。

    开发遇坑记录

    在开发过程中,我们遇到了一些典型的逻辑与实现问题。以下是关键问题的总结与解决方案,供大家参考避坑:

  • 护盾永久无敌 Bug

    • 问题现象:碰撞检测到护盾后,仅判断 hasShield 为 true,但未将其置为 false,导致护盾永不消耗,飞机处于永久无敌状态。
    • 解决方案:在护盾成功抵挡一次伤害后,必须立即将 hasShield 手动设置为 false,确保护盾被正确消耗。
  • 清屏后无新障碍物生成

    • 问题现象:使用清屏道具后,仅将障碍物的 isAlive 设为 false,但障碍物不会立即复活,导致屏幕上一段时间内没有障碍物。
    • 解决方案:障碍物复活依赖于 reset() 方法。清屏后,障碍物仍需移动至屏幕底部并触发 isOutOfScreen() 判断,才会调用 reset() 重新激活。因此,清屏效果是“标记死亡”,而非立即生成新障碍物。
  • 避免使用 Thread.sleep() 实现等待

    • 问题现象:在 Swing 事件分发线程(EDT)中调用 Thread.sleep() 会导致界面完全卡死,鼠标事件与绘图渲染均无响应。
    • 解决方案:改用时间戳比对实现延时逻辑。例如,记录某个动作的开始时间,在游戏主循环中检查当前时间与开始时间的差值,达到预定间隔后再执行后续操作,从而避免阻塞 EDT。
  • 方法定义未调用

    • 问题现象:编写了 checkPlaneHitObstacle() 碰撞检测方法,但在主逻辑中忘记调用,导致方法从未执行,功能缺失。
    • 解决方案:在 GameListener 的游戏主循环或相应事件处理中显式调用该方法,确保碰撞检测逻辑生效。
  • 鼠标与飞机中心点错位

    • 问题现象:在 mouseMoved 事件中已将鼠标坐标转换为飞机中心坐标,但在 paintComponent 绘制飞机时又额外减去了 15 像素的偏移,导致飞机显示位置与鼠标控制位置不一致。
    • 解决方案:确保坐标转换逻辑唯一。既然 mouseMoved 中已完成中心点换算,绘图时直接使用 planeX 和 planeY 作为飞机中心坐标即可,无需再次偏移。
  • 避免使用帧计数器统计游戏时间

    • 问题现象:依赖 Timer 触发次数(帧数)来累加时间,由于 Timer 执行间隔可能波动或丢帧,导致游戏计时不准。
    • 解决方案:改用 System.currentTimeMillis() 获取系统真实时间戳。在游戏开始时记录一个起始时间戳,每次更新时计算与起始时间的差值,从而得到精确的游戏时长,从根本上解决因帧率不稳定导致的计时误差。
  • 总结

    到这里,我们修复了上一版本遗留的全部Bug,完成了护盾抵挡单次伤害、清屏重置障碍物、系统时间计时、开局延时生成障碍等功能。

    本次开发让我体会到面向对象编程带来的便利:将子弹、障碍物、道具各自的数据与行为封装在独立类中,监听器只负责事件接收与逻辑调度,做到职责分离。同时也踩了不少Swing图形编程的经典坑点:不能使用sleep()阻塞UI线程、定义方法不等于调用、护盾伤害需要手动置空状态变量,这些都是GUI开发很容易忽略的细节。

    游戏目前依旧还有可以完善的地方:分数系统、游戏结束弹窗、重新开始游戏、随游戏时间动态提升难度都还没有实现。

    下期展望

    下一篇博客,我们将新增计分统计、游戏结束界面,实现游戏重启功能;同时优化游戏难度,让障碍物速度随游玩时间逐渐加快,进一步完善整个小游戏。

    代码运行:
    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述

    赞(0)
    未经允许不得转载:171主机测评 » 从零手写Java飞机躲障碍游戏|Swing绘图、鼠标跟随、计时器碰撞检测实战(五)
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址