[Java]Swing版坦克大战小游戏项目开发(2)——熟悉paint方法和监听器

256 阅读8分钟

本节概述

在本节,你会了解到 Swing 的 Frame 类的 paint 方法以及键盘监听器。paint 方法可以绘制窗口的内部的图形,通常窗口启动时自动调用。键盘监听器可以监听你按下键盘的事件然后做出一定的处理。

绘制一个矩形

TankFrame 类中,重写 paint 方法:

@Override
public void paint(Graphics g) {
    g.fillRect(100, 100, 50, 50);
}

Graphics 对象是 Swing 库提供的画笔对象,通过这个“画笔”,我们可以绘制图形。g.fillRect(100, 100, 50, 50) 方法表示在 (100,100)(100, 100) 坐标位置绘制一个长宽均为 5050 px 矩形。

试着运行一下:

image.png

可以看到窗口的左上方出现了一个矩形黑块。学过笛卡尔坐标系的同学可能有点疑惑,为什么是在左上角而不是左下角?因为通常在 GUI 开发领域中,(0,0)(0, 0) 坐标是从左上角开始的,用一个简单的图表示一下:

image.png

这就不难理解为什么矩形块出现在左上角了。

定义Tank类与窗口解耦

试想一下,如果全都在 TankFrame 中实现坦克的全部逻辑的话,那么代码必然会变得十分臃肿。所以我们需要把所有关于坦克的逻辑单独封装到一个类中,这个类我们就定义为 Tank

package org.codeart;

import java.awt.*;

public class Tank {
    
    // X 轴坐标
    private int x;
    
    // Y 轴坐标
    private int y;
    
    // 方向的枚举类
    private Dir dir;

    // 默认的速度 10 像素
    private static final int SPEED = 10;

    public Tank(int x, int y, Dir dir) {
        super();
        this.x = x;
        this.y = y;
        this.dir = dir;
    }

    public Dir getDir() {
        return dir;
    }

    public void setDir(Dir dir) {
        this.dir = dir;
    }

    public void paint(Graphics g) {
        g.fillRect(x, y, 50, 50);
        switch (dir) {
            case LEFT:
                x -= SPEED;
                break;
            case RIGHT:
                x += SPEED;
                break;
            case UP:
                y -= SPEED;
                break;
            case DOWN:
                y += SPEED;
                break;
        }
    }
}

方向的枚举类 Dir,分别表示左、上、右、下:

public enum Dir {
    
    LEFT, UP, RIGHT, DOWN;
}

在坦克类中,我们重写了 paint 方法用于实现 TankFrame 类的 paint 方法,这才是真的实现了绘制逻辑的代码。

重构TankFrame类

到这一步为止,坦克的逻辑就已经初步成型了。下面就要重写 TankFrame 的逻辑了,定义 tank 成员变量然后初始化赋值:

public class TankFrame extends Frame {

    private final Tank tank;

    public TankFrame() throws HeadlessException {
        setVisible(true);
        setSize(800, 600);
        setResizable(false);
        setTitle("War of Tank");
        setLocationRelativeTo(null);
        addWindowListener(new WindowAdapter() {
            @Override
            public void windowClosing(WindowEvent e) {
                System.exit(0);
            }
        });

        // 初始化坦克对象
        this.tank = new Tank(0, 0, Dir.DOWN);
    }

    @Override
    public void paint(Graphics g) {
        tank.paint(g);
    }
}

这样就实现了窗口对象与坦克对象的解耦。

自定义键盘监听器

下面我们需要让窗口监听到键盘事件,这就需要自定义键盘监听器类。定义 IListener 类继承 KeyAdapter 类:

// 目前在 TankFrame 中定义键盘监听器内部类,后续再处理耦合问题
private class IListener extends KeyAdapter {

    // 是否向左
    boolean bL;

    // 是否向上
    boolean bU;

    // 是否向右
    boolean bR;

    // 是否向下
    boolean bD;

    // 监听键盘按下的事件
    @Override
    public void keyPressed(KeyEvent e) {
        int key = e.getKeyCode();
        switch (key) {
            case KeyEvent.VK_LEFT:
                bL = true;
                break;
            case KeyEvent.VK_UP:
                bU = true;
                break;
            case KeyEvent.VK_RIGHT:
                bR = true;
                break;
            case KeyEvent.VK_DOWN:
                bD = true;
                break;
            default:
                break;
        }
        setDir();
    }

    // 监听键盘释放的事件
    @Override
    public void keyReleased(KeyEvent e) {
        int key = e.getKeyCode();
        switch (key) {
            case KeyEvent.VK_LEFT:
                bL = false;
                break;
            case KeyEvent.VK_UP:
                bU = false;
                break;
            case KeyEvent.VK_RIGHT:
                bR = false;
                break;
            case KeyEvent.VK_DOWN:
                bD = false;
                break;
            default:
                break;
        }
        setDir();
    }

    // 重新设置方向
    private void setDir() {
        if (bL) {
            tank.setDir(Dir.LEFT);
        }
        if (bU) {
            tank.setDir(Dir.UP);
        }
        if (bR) {
            tank.setDir(Dir.RIGHT);
        }
        if (bD) {
            tank.setDir(Dir.DOWN);
        }
    }
}

在TankFrame中添加监听器

下面在 TankFrame 中需要添加我们自己定义的监听器:

public TankFrame() throws HeadlessException {
    setVisible(true);
    setSize(800, 600);
    setResizable(false);
    setTitle("War of Tank");
    setLocationRelativeTo(null);
    // 添加自定义监听器
    addKeyListener(new IListener());
    addWindowListener(new WindowAdapter() {
        @Override
        public void windowClosing(WindowEvent e) {
            System.exit(0);
        }
    });
    this.tank = new Tank(0, 0, Dir.DOWN);
}

让坦克动起来

在一切准备就绪之后,我们就可以考虑让坦克动起来了。在一个死循环中每隔 200ms 重绘一次窗口,这样就可以实现动画效果:

public class FrameDemo {

    public static void main(String[] args) throws InterruptedException {
        TankFrame frame = new TankFrame();
        while (true) {
            Thread.sleep(200);
            frame.repaint();
        }
    }

}

试着运行一下:

坦克.gif

添加坦克静止状态的处理

目前,我们已经实现了让坦克动起来,但是坦克会一直动不会静止,这有点不太符合坦克大战的逻辑。所以现在我们需要给坦克添加一个静止的状态,让坦克保持静止。

Tank 类内部添加一个 moving 成员变量,类型是 boolean,表示是否在移动中。再封装一个 move 方法用于实现坦克移动的逻辑。

public class Tank {
    
    // ...
    
    // 坦克的运动状态默认是 false 表示静止状态
    private boolean moving;
    

    public boolean isMoving() {
        return moving;
    }

    public void setMoving(boolean moving) {
        this.moving = moving;
    }
    

    public void paint(Graphics g) {
        g.fillRect(x, y, 50, 50);
        move();
    }

    private void move() {
        if (!moving) {
            return;
        }
        switch (dir) {
            case LEFT:
                x -= SPEED;
                break;
            case RIGHT:
                x += SPEED;
                break;
            case UP:
                y -= SPEED;
                break;
            case DOWN:
                y += SPEED;
                break;
        }
    }   
}

最后在 IListener 内部类的 setDir 方法中判断并设置坦克的移动状态:

private void setDir() {
    if (!bL && !bR && !bU && !bD) {
        tank.setMoving(false);
        return;
    }
    tank.setMoving(true);
    if (bL) {
        tank.setDir(Dir.LEFT);
    }
    if (bU) {
        tank.setDir(Dir.UP);
    }
    if (bR) {
        tank.setDir(Dir.RIGHT);
    }
    if (bD) {
        tank.setDir(Dir.DOWN);
    }
}

现在我把重绘的时间间隔调为 10ms,这样刷新(帧率)更快一点:

public static void main(String[] args) throws InterruptedException {
    TankFrame frame = new TankFrame();
    while (true) {
        Thread.sleep(10);
        frame.repaint();
    }
}

坦克2.gif

定义Bullet类,画出子弹

现在我们需要定义 Bullet 子弹类:

package org.codeart.tank.model;

import org.codeart.Dir;

import java.awt.*;

/**
 * 子弹类
 */
public class Bullet {

    /**
     * 子弹初始速度为 10 像素
     */
    private static final int SPEED = 10;

    /**
     * 子弹宽度
     */
    private static final int WIDTH = 10;

    /**
     * 子弹高度
     */
    private static final int HEIGHT = 10;
    
    private int x;
    
    private int y;
    
    private Dir dir;

    public Bullet(int x, int y, Dir dir) {
        this.x = x;
        this.y = y;
        this.dir = dir;
    }

    public void paint(Graphics g) {
        Color color = g.getColor();
        g.setColor(Color.RED);
        g.fillOval(x, y, WIDTH, HEIGHT);
        g.setColor(color);
        move();
    }

    private void move() {
        switch (dir) {
            case LEFT:
                x -= SPEED;
                break;
            case RIGHT:
                x += SPEED;
                break;
            case UP:
                y -= SPEED;
                break;
            case DOWN:
                y += SPEED;
                break;
        }
    }
}

还是跟坦克一样的逻辑,paint 方法用于绘制子弹。move 方法用于移动子弹。子弹的初始宽高为 5 像素。

再在 TankFrame 中定义出来:

private final Bullet bullet = new Bullet(300, 300, Dir.DOWN);

然后在 TankFramepaint 方法中调用子弹的 paint 方法:

@Override
public void paint(Graphics g) {
    tank.paint(g);
    bullet.paint(g);
}

运行看一下效果,这次我把绘制间隔调低到 200ms 防止速度太快看不清楚。

子弹.gif

解决屏幕闪烁问题

写到这里我们会发现游戏窗口总是会闪烁,现在我们来解决这个问题。可以使用双缓冲方法解决闪烁问题,这是游戏开发中的专业术语,我们了解一下即可,没必要深究。粘贴以下代码即可:


private Image offScreenImage;

public static final int GAME_WIDTH = 800;

public static final int GAME_HEIGHT = 600;

// 常量抽出来之后要改一下构造方法代码
public TankFrame() throws HeadlessException {
    setVisible(true);
    setSize(GAME_WIDTH, GAME_HEIGHT);
    // ...
}

@Override
public void update(Graphics g) {
    if (offScreenImage == null) {
        offScreenImage = this.createImage(GAME_WIDTH, GAME_HEIGHT);
    }
    Graphics gOffScreen = offScreenImage.getGraphics();
    Color c = gOffScreen.getColor();
    gOffScreen.setColor(Color.BLACK);
    gOffScreen.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT);
    gOffScreen.setColor(c);
    paint(gOffScreen);
    g.drawImage(offScreenImage, 0, 0, null);
}

因为我把底色改成了黑色,所以需要把坦克的颜色改一下才能看到效果:

// 把坦克改成黄色
public void paint(Graphics g) {
    Color c = g.getColor();
    g.setColor(Color.YELLOW);
    g.fillRect(x, y, 50, 50);
    g.setColor(c);
    move();
}

闪烁.gif

闪烁问题解决。其实闪烁问题原理总得来说就是一句话:屏幕闪烁得很快,但是程序后台的计算速度又跟不上就会导致不同步进而发生闪烁现象。有兴趣可以了解一下垂直同步的概念。

让坦克打出子弹

下面来实现坦克打出子弹的效果。我们规定按下空格键发出子弹。监听器的代码修改如下:

@Override
public void keyReleased(KeyEvent e) {
    int key = e.getKeyCode();
    switch (key) {
        // ...
        case KeyEvent.VK_SPACE:
            tank.fire();
            break;
        default:
            break;
    }
    setDir();
}

Tank 类中定义 fire 方法表示打出子弹:

/**
 * 射出子弹
 */
public void fire() {
    tankFrame.bullet = new Bullet(x, y, dir);
}

这里引用了 TankFrame 类,所以得在构造方法中声明 TankFrame

private final TankFrame tankFrame;

public Tank(int x, int y, Dir dir, TankFrame tankFrame) {
    super();
    this.x = x;
    this.y = y;
    this.dir = dir;
    this.tankFrame = tankFrame;
}

TankFrame 传进来才可以画坦克打出来的子弹。

TankFrame 类的 bullet 属性需要临时修改一下:

public Bullet bullet = new Bullet(300, 300, Dir.DOWN);

这样才可以修改 bullet 属性值。运行看一下效果:

闪烁.gif

实现打出多发子弹

在上一节,我们已经实现了打出一发子弹。这一节我们来实现打出多发子弹,思路也比较简单。那就是让 TankFrame 持有一个 List<Bullet> 类型的列表,用来存储子弹:

public final List<Bullet> bullets = new ArrayList<>();

如何把子弹加到集合内部呢?因为坦克类持有 TankFrame 的引用,所以可以直接调用 add 方法添加子弹,修改 fire 方法如下:

/**
 * 射出子弹
 */
public void fire() {
    tankFrame.bullets.add(new Bullet(x, y, dir, tankFrame));
}

同时我们也需要能删除子弹,所以也要让 Bullet 类持有 TankFrame 的引用,构造方法修改如下:

private TankFrame tankFrame;

public Bullet(int x, int y, Dir dir, TankFrame tankFrame) {
    this.x = x;
    this.y = y;
    this.dir = dir;
    this.tankFrame = tankFrame;
}

在打出一发子弹的时候,我们同时也需要做边界判断,判断子弹是否到达游戏边界。这里我们需要设置一个成员变量 alive,默认是 true

private boolean alive = true;

同时,也需要修改 paint 方法:

public void paint(Graphics g) {
    // 子弹越界之后需要删除子弹对象
    if (!alive) {
        tankFrame.bullets.remove(this);
        return;
    }
    Color color = g.getColor();
    g.setColor(Color.RED);
    g.fillOval(x, y, WIDTH, HEIGHT);
    g.setColor(color);
    move();
}

修改 move 方法用于判断子弹是否越界:

private void move() {
    switch (dir) {
        case LEFT:
            x -= SPEED;
            break;
        case RIGHT:
            x += SPEED;
            break;
        case UP:
            y -= SPEED;
            break;
        case DOWN:
            y += SPEED;
            break;
    }
    
    if (x <= 0 || y <= 0 ||x > TankFrame.GAME_WIDTH || y > TankFrame.GAME_HEIGHT) {
        alive = false;
    }
}

最后一步就是让 TankFrame 来绘制子弹,需要修改 paint 方法:

@Override
public void paint(Graphics g) {
    // 显示子弹的数量
    Color c = g.getColor();
    g.setColor(Color.WHITE);
    g.drawString("Number of bullets: " + bullets.size(), 10, 60);
    g.setColor(c);
    
    tank.paint(g);
    
    // 这里使用 for-i 循环是否为了防止 ConcurrentModificationException
    // 不熟悉的可以百度一下为什么两个集合的引用不能同时操作集合对象本身
    // 可以了解一下 AbstractList 的 modCount 变量
    for (int i = 0; i < bullets.size(); i++) {
        bullets.get(i).paint(g);
    }
}

运行一下看看效果:

闪烁.gif