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

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

上期回顾

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

本章功能实现

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

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

以下为已处理的道具图片,大家要用自行取用~

本期使用的类有:

Main(程序入口),GameListener(监听器),DrawPanel(绘制),Obstacles(障碍物),Bullet(子弹),Item(道具)

1.Main类的程序逻辑

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

·创建showUI方法,用于实现上述功能:

java 复制代码
    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方法

java 复制代码
    static void main() {
        Main main=new Main();
        main.showUI();
    }

此时,我们运行起来是这样的:

2.设置Obstacles类与Bullet类

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

·设置变量

java 复制代码
    public int x,y,speedX;
    public int width=8,height=16;
    public boolean isAlive;

这个speedX是我新增的变量,用于控制子弹在水平方向的移动速度

比如说:

当speedX=0:子弹水平方向不偏移,竖直向上飞

当speedX=-6:x方向不断减少,子弹向左上方飞行

先说一下,这个与我们后来的霰弹功能有关

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

java 复制代码
    public Bullet(int x, int y, int speedX){
        this.x=x;
        this.y=y;
        this.speedX=speedX;
        isAlive=true;
    }

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

java 复制代码
    public void move(){
        x += speedX;
        y -= 12;
    }
}

子弹图片:

接下来是 Obstacle 类的核心逻辑

Obstacle 类定义了游戏中障碍物的基本属性和行为。以下是其核心代码实现:

设置变量

障碍物的坐标(x,y)与障碍物的大小,状态(已击碎/未击碎)

java 复制代码
    public int x,y;
    public int width,height;
    public boolean isAlive;

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

java 复制代码
    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 轴(垂直方向)向下移动,实现持续下落的效果。

java 复制代码
    public void move(int speed){
        y += speed;
    }

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

java 复制代码
    public boolean isOutOfScreen(){
        return y > 800;
    }

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

java 复制代码
    //障碍物重置复活,飞到屏幕顶端外面,给新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: 道具是否处于活动状态(是否可被拾取)。

java 复制代码
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)。

java 复制代码
    public Item(int x,int y,int type){
        this.x=x;
        this.y=y;
        this.type=type;
        isAlive=true;
    }

** move()方法 **:

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

java 复制代码
    //道具向下移动
    public void move(){
        y +=4;
    }

isOutOfScreen()方法

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

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

draw(Graphics g, Image shieldImg, Image clearImg, Image fireImg)方法

根据 type 的值,使用 switch 语句选择对应的图片进行绘制。

仅在道具存活(isAlivetrue)时执行绘制,避免绘制无效道具。

java 复制代码
    //绘制道具,接收三张道具图片
    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) 算法进行矩形碰撞检测,逻辑清晰高效。

同样,只在道具存活时进行检测,避免与已失效的道具发生交互。

java 复制代码
    //检测飞机和道具碰撞
    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;
    }
}

设计要点

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

4. DrawPanel 类的代码逻辑

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

java 复制代码
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音频文件并准备播放

java 复制代码
    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文件");
        }
    }

播放背景音乐

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

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

停止背景音乐

java 复制代码
    public void stopBgm(){
        if(bgMusic!=null){
            bgMusic.stop();
        }
    }

DrawPanel构造函数 - 初始化游戏面板

设置初始参数、加载资源、创建UI组件

java 复制代码
    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(); // 加载背景音乐
    }

障碍物移动逻辑

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

java 复制代码
    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));
            }
        }
    }

子弹移动与碰撞检测

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

java 复制代码
    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表示未碰撞

java 复制代码
    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; // 未发生碰撞
    }

重写绘制方法

根据游戏状态绘制不同的界面

java 复制代码
    @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);
        }
    }

时间分数显示

java 复制代码
    /**
     * 更新时间显示
     */
    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():背景音乐的加载、播放和停止控制。

设计要点:

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

5. GameListener 类代码实现

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

java 复制代码
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 作为游戏的事件驱动与逻辑调度中心,承担了以下核心职责:

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

设计要点

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

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

开发遇坑记录

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

  1. 护盾永久无敌 Bug

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

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

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

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

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

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

总结

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

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

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

下期展望

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

代码运行:

相关推荐
frjc1 小时前
阿里云 ACK 环境 Arthas 在线调试指南
开发语言·python
Python私教1 小时前
如意智影:如何让同一个人物在多个镜头里保持身份一致
人工智能·python·架构
莫得感情 o1 小时前
踩坑 - 压测三轮后 502:一个默认 10 的连接池如何拖垮整个服务
java
Escalating_xu1 小时前
【Linux线程】线程控制全解析:终止、join/detach、cancel、线程栈与 NPTL(下篇)
java·linux·运维
2602_959960922 小时前
电商大厂Java面试实录:Spring Boot/JVM/Redis/Kafka/微服务/安全/测试全解析
java·jvm·spring boot·redis·面试题
yangmu32032 小时前
《明末:渊虚之羽》1.7版百款MOD终极指南:硬核配置与保姆级安装教程
游戏
高洁012 小时前
智能体的“记忆”难题
python·深度学习·机器学习·transformer
三十岁老牛再出发2 小时前
8月21日总结
c++·python
吃饱了得干活2 小时前
限界上下文之后:微服务怎么拆、上下文怎么聊?
java·后端·架构