Java GUI 进阶:Swing 游戏开发实战 —— 时序乱斗俯视角射击 Demo

前言

最近自学 Java,想要巩固线程、集合、面向对象、GUI 相关知识,于是原生使用 Swing 开发了这款名为《时序乱斗》的 2D 俯视角射击 Demo。项目不引入任何第三方游戏引擎,移动、射击、碰撞等全部底层逻辑手动实现。
项目采用分阶段迭代开发,按照真实写代码的顺序逐步完善功能。

开发阶段划分(真实开发顺序)

阶段1:创建窗口,WASD控制蓝色方块移动
阶段2:增加窗口边界限制,新增墙体,玩家无法穿墙
阶段3:鼠标左键点击,向鼠标位置发射匀速飞行子弹(此时子弹不会自动销毁)
阶段4:实现子弹生命周期管理,子弹飞出窗口、撞击墙体时自动销毁

技术栈

Java SE、Swing GUI、固定FPS游戏循环、InputMap按键监听、AWT矩形碰撞、向量归一化、ArrayList实体管理

项目结构

zsq20260724
├─ GameMain.java    // 程序入口,创建窗口、启动游戏线程
└─ GamePanel.java   // 游戏画布,包含渲染、游戏循环、所有业务逻辑

GameMain.java 完整代码

package zsq20260724;

import javax.swing.*;

public class GameMain {
    public static void main(String[] args) {
        SwingUtilities.invokeLater(() -> {
            JFrame frame = new JFrame("时序乱斗");
            frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
            frame.setResizable(false);

            GamePanel gamePanel = new GamePanel();
            frame.add(gamePanel);
            frame.pack();
            frame.setLocationRelativeTo(null);
            frame.setVisible(true);

            // 开启独立子线程运行游戏循环,防止界面卡死
            new Thread(gamePanel).start();
        });
    }
}

GamePanel.java 原始开发版本代码

package zsq20260724;
import javax.swing.*;
import java.awt.*;
import java.awt.event.KeyEvent;
import java.util.HashMap;
import java.util.Map;
import java.awt.event.*;
import java.util.List;
import java.util.ArrayList;

public class GamePanel extends JPanel implements Runnable {
    public static final int WIDTH = 900;
    public static final int HEIGHT = 600;
    private static final int FPS = 60;
    private final long TIME_PER_FRAME = 1000000000 / FPS;

    private final Map<Integer, Boolean> keyState = new HashMap<>();

    private int playerX = 100;
    private int playerY = 100;
    private final int playerSize = 40;
    private final int speed = 5;

    //墙体
    private final int wallX = 400;
    private final int wallY = 200;
    private final int wallW = 120;
    private final int wallH = 220;

    //存放所有子弹
    private List<Bullet> bulletList = new ArrayList<>();
    //子弹内部类
    class Bullet{
        double x,y;
        double dx,dy;
        int size = 10;
        int speed = 7;

        public Bullet(double startX,double startY,double targetX,double targetY){
            x = startX;
            y = startY;
            double disX = targetX - startX;
            double disY = targetY - startY;
            double len = Math.sqrt(disX*disX + disY*disY);
            dx = disX/len;
            dy = disY/len;
        }
        public void update(){
            x += dx*speed;
            y += dy*speed;
        }
    }

    // 按键常量
    private static final int W = KeyEvent.VK_W;
    private static final int S = KeyEvent.VK_S;
    private static final int A = KeyEvent.VK_A;
    private static final int D = KeyEvent.VK_D;

    public GamePanel() {
        setPreferredSize(new Dimension(WIDTH, HEIGHT));
        setBackground(Color.BLACK);
        initKeyBind();

        //左键发射子弹
        addMouseListener(new MouseAdapter() {
            @Override
            public void mousePressed(MouseEvent e){
                double playerCenterX = playerX + playerSize/2.0;
                double playerCenterY = playerY + playerSize/2.0;
                Bullet b = new Bullet(playerCenterX,playerCenterY,e.getX(),e.getY());
                bulletList.add(b);
            }
        });
    }

    // 绑定按键映射,摆脱焦点限制
    private void initKeyBind() {
        InputMap inputMap = getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW);
        ActionMap actionMap = getActionMap();

        bindKey(inputMap, actionMap, W, true);
        bindKey(inputMap, actionMap, W, false);
        bindKey(inputMap, actionMap, S, true);
        bindKey(inputMap, actionMap, S, false);
        bindKey(inputMap, actionMap, A, true);
        bindKey(inputMap, actionMap, A, false);
        bindKey(inputMap, actionMap, D, true);
        bindKey(inputMap, actionMap, D, false);
    }

    private void bindKey(InputMap inputMap, ActionMap actionMap, int keyCode, boolean pressed) {
        String name = keyCode + "_" + pressed;
        inputMap.put(KeyStroke.getKeyStroke(keyCode, 0, !pressed), name);
        actionMap.put(name, new AbstractAction() {
            @Override
            public void actionPerformed(java.awt.event.ActionEvent e) {
                keyState.put(keyCode, pressed);
            }
        });
    }

    //碰撞判断工具方法
    private boolean checkCollision(int x1,int y1,int w1,int h1,int x2,int y2,int w2,int h2){
        Rectangle rect1 = new Rectangle(x1,y1,w1,h1);
        Rectangle rect2 = new Rectangle(x2,y2,w2,h2);
        return rect1.intersects(rect2);
    }

    private void updateGame() {
        //先保存移动前坐标
        int oldX = playerX;
        int oldY = playerY;

        //尝试移动
        if (keyState.getOrDefault(W, false)) playerY -= speed;
        if (keyState.getOrDefault(S, false)) playerY += speed;
        if (keyState.getOrDefault(A, false)) playerX -= speed;
        if (keyState.getOrDefault(D, false)) playerX += speed;

        // 边界限制
        playerX = Math.max(0, Math.min(playerX, WIDTH - playerSize));
        playerY = Math.max(0, Math.min(playerY, HEIGHT - playerSize));

        //检测和墙体碰撞,撞上就退回原来位置
        if(checkCollision(playerX,playerY,playerSize,playerSize,wallX,wallY,wallW,wallH)){
            playerX = oldX;
            playerY = oldY;
        }
        for (int i = 0;i<bulletList.size();i++){
            Bullet b = bulletList.get(i);
            b.update();

            //条件1:飞出画面
            boolean outScreen = b.x<0 || b.y<0 || b.x>WIDTH || b.y>HEIGHT;
            //条件2:撞到墙体
            boolean hitWall = checkCollision((int)b.x,(int)b.y,b.size,b.size,wallX,wallY,wallW,wallH);

            //满足删掉这颗子弹
            if (outScreen || hitWall){
                bulletList.remove(i);
                i--;//删掉后下标对齐,防止跳过子弹
            }
        }
    }

    @Override
    protected void paintComponent(Graphics g) {
        super.paintComponent(g);
        //灰色墙体
        g.setColor(Color.GRAY);
        g.fillRect(wallX,wallY,wallW,wallH);
        //子弹
        g.setColor(Color.YELLOW);
        for (int i= 0;i<bulletList.size();i++){
            Bullet b = bulletList.get(i);
            g.fillRect((int)b.x,(int)b.y,b.size,b.size);
        }
        //玩家
        g.setColor(new Color(60, 130, 255));
        g.fillRect(playerX, playerY, playerSize, playerSize);
    }

    @Override
    public void run() {
        long lastTime = System.nanoTime();
        long now;
        while (true) {
            now = System.nanoTime();
            if (now - lastTime >= TIME_PER_FRAME) {
                updateGame();
                repaint();
                lastTime = now;
            }
        }
    }
}

分阶段开发讲解

阶段1:WASD玩家移动

搭建窗口、游戏循环,实现按键监听,玩家方块可以自由移动。
采用InputMap+ActionMap按键方案,区别老旧KeyListener,支持多键同时按下斜向移动。
此时:没有墙体、没有边界约束、没有子弹。

阶段2:窗口边界 + 墙体碰撞

新增墙体参数、坐标边界钳位代码、矩形碰撞工具方法。
实现效果:玩家不能走出屏幕;碰到墙体无法穿过。
逻辑思路:先移动,碰撞后回退备份坐标。

阶段3:鼠标点击发射子弹

新增Bullet内部类、鼠标监听器、向量归一化计算飞行方向。
向量归一化保证:无论鼠标远近,子弹匀速飞行。
阶段3完成遗留问题:子弹不会销毁,不断发射会持续占用内存。

阶段4:子弹自动销毁

遍历子弹集合,判断出界、撞击墙体,执行删除。
重点知识点:普通for循环删除集合元素,需要i--修正下标,避免循环漏掉子弹。

重点知识点拓展

1. GamePanel为什么继承JPanel

JPanel是Swing内置画布组件,继承后自动拥有绘图paintComponent、鼠标监听、repaint()刷新、尺寸设置等大量GUI能力。
Java只支持单继承,已经继承JPanel,因此无法再继承MouseAdapter、AbstractAction这类抽象类。
所以鼠标、按键事件只能使用匿名内部类实现。

2. new AbstractAction(){} 说明

属于匿名内部类。AbstractAction是抽象类,不是接口,不能使用Lambda表达式简写。
按键触发时,Swing自动回调actionPerformed方法,更新keyState里按键按下/松开状态。

3. 按键监听两种方案对比

简易替代方案:KeyListener
优点:代码简短,新手入门常见;
缺点:依赖组件焦点、多键同时按下容易失效,不适合游戏。

最终选用方案:InputMap + ActionMap
优点:窗口激活即可响应按键、多键输入稳定;
缺点:代码复杂,理解门槛偏高。

运行操作

WASD移动蓝色方块
鼠标左键点击,发射黄色子弹

后续拓展规划

下一阶段:新增 AI 敌人,实现敌人自动追踪玩家。后续还可以实现角色朝向跟随鼠标旋转

欢迎大家在评论区交流学习心得,如有疑问、代码优化思路都可以留言讨论

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐