《Java游戏编程原理与实践教程》读书笔记(第4章——Java游戏程序的基本框架)

第4章 Java游戏程序的基本框架

4.1 动画的类型及帧频

4.1.1 动画类型

动画分为影视动画和游戏动画两种。

游戏动画是在屏幕上显示一系列连续动画画图的第一帧图形,然后在每隔很短时间显示下一帧图像,如此反复,利用人眼的视觉暂留现象而感觉好像画面的物体在运动。

也就是说游戏动画就是图像在很短时间间隔内连续显示。

4.1.2 设置合理的帧频

FPS就是每秒钟的帧数,即每秒显示多少张图像,每一帧就是一幅静态图像,电影的播放速度是24FPS,游戏速度达到10FPS就能明显感觉到动画的效果了。

但是屏幕上显示的图像越大,占用的内存越多,处理的速度就越慢,所以需要在显示大小和FPS间做一个权衡。

4.2 游戏动画的制作

4.2.1 绘制动画以及动画循环

动画是一连串的图像快速循环播放,所以需要用到循环语句(while、for等)控制图像的连续播放。

又由于动画需要一定的播放速度,所以需要连续播放动画的同时能够控制动画的播放速度,所以需要使用线程中的暂停函数(Thread.sleep())来控制。

一般来说希望动画无限制地播放,示例代码如下:

while(true){ // 死循环
    处理游戏功能;
    使用repaint()函数要求重画屏幕;
    暂停一小段时间;
}

在Java 游戏程序中, 通过repaint()函数请求屏幕的重画, 可以请求重画全部屏幕,也可以请求重画部分屏幕。

下面以一个自由落体小球动画来演示:实现自由落体动画,首先设计一个自由降落的小球,同时控制降落的速度。控制速度就需要实现个继承了Runnable 线程接口和继承了JPanel类的TestPanel面板类。继承JPanel类是为了使用JPanel的Paint0方法来实现小球在屏幕上的绘制,继承Runnable线程接口可以实现动画的暂停控制。

class TestPanel extends JPanel implements Runnable {

    public TestPanel() {

    }

    @Override
    public void paint(Graphics g) {

    }

    @Override
    public void run() {

    }
}

在Java中创建线程的方法有两种:继承Thread类和实现Runnable接口。这里演示的是实现Runnable接口,需要new Thread(Runnable runnable).start();来启动线程。也就是说要传入一个Runnable接口的实现类作为参数,来启动线程。

    public TestPanel() {
        // 创建一个新线程,this就是实现了Runnable接口的实现类
        Thread t = new Thread(this);
        // 启动线程
        t.start();
    }

现在实现线程的run()方法,使用while(true)循环每隔30毫秒重新绘制动画场景,由于30毫秒很短,所以是连续的动画。这里采用Thread.sleep()方法来暂停30毫秒。

    @Override
    public void run() {
        while (true) {// 线程中的无限循环
            try {
                Thread.sleep(30);// 休眠30毫秒
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            ypos += 5;// 修改小球左上角的纵坐标
            if (ypos > 300) {// 小球离开窗口后重设左上角的纵坐标
                ypos = 80;
            }
            repaint();// 窗口重绘
        }
    }

小球的重新绘制将在paint()方法中实现,通过不断更改小球要显示的y坐标,实现小球的自由落体,同时还要清除上一次(也就是30毫秒前)显示的小球,就可以看到看到小球的自由落体动画了。

    @Override
    public void paint(Graphics g) {
        g.clearRect(0, 0, this.getWidth(), this.getHeight());// 先清除屏幕上原来的画
        g.setColor(Color.GREEN);// 设置小球的颜色
        g.fillOval(0, ypos, 80, 80);// 绘制小球
    }

clearRect()方法内是要清除的范围,前两个参数是x坐标和y坐标,后两个参数是宽度和高度,也就是一个矩形,这里是清理整个屏幕。

小球的下落通过不断改变小球的显示y坐标来达到目的。该类的完整代码如下:

class TestPanel extends JPanel implements Runnable {
    int ypos = 0;

    public TestPanel() {
        // 创建一个新线程,this就是实现了Runnable接口的实现类
        Thread t = new Thread(this);
        // 启动线程
        t.start();
    }

    @Override
    public void paint(Graphics g) {
        g.clearRect(0, 0, this.getWidth(), this.getHeight());// 先清除屏幕上原来的画
        g.setColor(Color.GREEN);// 设置小球的颜色
        g.fillOval(0, ypos, 80, 80);// 绘制小球
    }

    @Override
    public void run() {
        while (true) {// 线程中的无限循环
            try {
                Thread.sleep(30);// 休眠30毫秒
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            ypos += 5;// 修改小球左上角的纵坐标
            if (ypos > 300) {// 小球离开窗口后重设左上角的纵坐标
                ypos = 80;
            }
            repaint();// 窗口重绘
        }
    }
}

public class Demo {
    public static void main(String[] args) {
        JFrame frame = new JFrame();
        frame.setLocation(200, 200);
        frame.setSize(500, 500);

        TestPanel panel = new TestPanel();
        frame.setContentPane(panel);

        frame.setVisible(true);
        frame.setResizable(false);
        frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
    }
}

效果如下图:

4.2.2 消除动画闪烁现象——双缓冲技术

一个动画在运行的时候,如果图像的切换是在屏幕上完成的,则可能会造成屏幕的闪烁,消除动画闪烁现象的最佳方法是使用双缓冲技术。

双缓冲技术在屏幕外做一个图像缓冲区, 事先在这个缓冲区内绘制图像,然后再将这个图像送到屏幕上,虽然动画中的图像切换很频繁,但是双缓冲技术很好地避免了在屏幕上进行消除和刷新时候的处理工作所带来的屏幕闪烁情况。但是在屏幕外的缓冲区需要占用一部分的内存资源,特别是图像比较大的时候,内存占用非常严重,因此-般需要考虑动画的质量和运行速度之间的重要性,有选择性地进行开发。

1.屏幕产生闪烁的原因

在Java游戏编程和动画编程中最常见的就是对于屏幕闪烁的处理。屏幕产生闪烁的原因是先用背景色覆盖组件再重绘图像的方式造成的。

运行上面简单的小球下落动画程序后,我们会看到窗体中有一一个从上至下匀速运动的小球,但仔细观察,你会发现小球会不时地被白色的不规则横纹隔开,即所谓的屏幕闪烁,这不是我们预期的结果。

Demo类的对象建立后,将显示窗口,程序首先自动调用重载后的paint(Graphics g)方法,在窗口上绘制一个小球,绘图线程启动后,该线程每隔30ms修改下小球的位置,然后调用repaint()方法。

注意,这个repaint()方法是从JPanel类继承而来的。它先调用update(Graphics g)方法,update(Graphics g)方法再调用paint(Graphics g)方法。先用背景色覆盖掉整个组件,然后再调用paint(Graphics g)方法重新绘制小球,这样每次都是在一个新的位置看到一个小球,前面的小球被背景色覆盖了,实现了动画的效果,但是,就是这种方式导致了动画闪烁,在两次看到不同位置小球的中间时刻,总数存在一个在短时间内被绘制出来的空白画面(颜色取背景色)。另外,用paint(Graphics g)方法在屏幕上直接绘图的时候,由于执行的语句比较多,程序不断地改变窗体中正在被绘制的图像,会造成绘制的缓慢,这也从一-定程度上加剧了闪烁。

闪烁效果视频如下(图片不能展示闪烁效果):

swing动画闪烁效果

使用双缓冲技术解决动画闪烁后的效果视频如下:

使用双缓冲技术解决swing动画闪烁后的效果

2.双缓冲技术

所谓双缓冲,就是在内存中开辟一片区域, 作为后台图像,程序对它进行更新、修改,绘制完成后再显示到屏幕上。

双缓冲技术的工作原理:先在内存中分配一个 与我们动画窗口-样大的空间( 在内存中的空间我们是看不到的),然后利用getGraphicsQ方法来获得双缓冲画笔,接着利用双缓冲画笔给空间描绘出我们想画的东西,最后将它全部一次性地显示到屏幕上。这样在我门的动画窗口上面显示出来就非常流畅了,避免了,上面的闪烁效果。

3.双缓冲的使用

一般采用重载paint(Graphics g)方法实现双缓冲,这种方式要求我们将双缓冲的处理放到paint(Graphics g)方法中。

完整代码如下:

class TestPanel extends JPanel implements Runnable {
    int ypos = 0;
    private Image iBuffer;
    private Graphics gBuffer;

    public TestPanel() {
        // 创建一个新线程,this就是实现了Runnable接口的实现类
        Thread t = new Thread(this);
        // 启动线程
        t.start();
    }

    @Override
    public void paint(Graphics g) {
        if (iBuffer == null) {
            iBuffer = createImage(this.getSize().width, this.getSize().height);
            gBuffer = iBuffer.getGraphics();
        }
        gBuffer.setColor(getBackground());
        gBuffer.fillRect(0, 0, this.getSize().width, this.getSize().height);
        gBuffer.setColor(Color.GREEN);
        gBuffer.fillOval(90, ypos, 80, 80);
        g.drawImage(iBuffer, 0, 0, this);
//        g.clearRect(0, 0, this.getWidth(), this.getHeight());// 先清除屏幕上原来的画
//        g.setColor(Color.GREEN);// 设置小球的颜色
//        g.fillOval(0, ypos, 80, 80);// 绘制小球
    }

    @Override
    public void run() {
        while (true) {// 线程中的无限循环
            try {
                Thread.sleep(30);// 休眠30毫秒
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            ypos += 5;// 修改小球左上角的纵坐标
            if (ypos > 300) {// 小球离开窗口后重设左上角的纵坐标
                ypos = 80;
            }
            repaint();// 窗口重绘
        }
    }
}

public class Demo {
    public static void main(String[] args) {
        JFrame frame = new JFrame();
        frame.setLocation(200, 200);
        frame.setSize(500, 500);

        TestPanel panel = new TestPanel();
        frame.setContentPane(panel);

        frame.setVisible(true);
        frame.setResizable(false);
        frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
    }
}

分析上述代码:我们首先添加了两个成员变量iBuffer和gBuffer 作为缓冲( 这就是所谓的双缓冲名称的来历)。在paint(Graphics g)方法中,首先检测如果iBuffer为null,则创建一个 与屏幕上的绘图区域大小一样的缓冲图像;再取得iBuffer的Graphics类型的对象的引用,并将其赋值给gBuffer;然后对gBuffer这个内存中的后台图像先用fllRect(int,int,int,int)清屏,再进行绘制操作,完成后将iBuffer直接绘制到屏幕上。

这段代码看似可以完美地完成双缓冲,但是运行之后我们看到的还是严重的闪烁。什么原因呢?问题还是出现在update(Graphics g)方法上。这段修改后的程序中的update(Graphics g)方法还是我们从父类继承的。在update(Graphics g)方法中, clearRect(int,int,int,int)对 前端屏幕进行了清屏操作,而在paint(Graphics g)方法中,对后台图像又进行了清屏操作。那么如果保留后台清屏,去掉多余的前台清屏应该就会消除闪烁。因此,我们只要重载update(Graphics g)方法即可:

public void update(Graphics g)
{
    paint(g);
}

双缓冲技术的原理如下:

  • (1)定义一个Graphics对象gBuffer和一个Image对象iBuffer。 按屏幕大小建立一个缓冲对象给iBuffer。然后取得iBuffer的Graphics赋给gBuffer。 此处可以把gBuffer理解为逻辑上的缓冲屏幕,而把iBuffer理解为缓冲屏幕上的图像。
  • (2)在gBuffer (逻辑上的屏幕)上绘制图像。
  • (3)将后台图像iBuffer全部--次性地绘制到我们的前台窗口。

注意,这个方法不是唯一解决闪烁的方法,在swing中,组件本身就提供了双缓冲功能,只需要进行方法调用就可以使用了,但是awt中没有提供。

4.3 使用定时器

定时器使用Timer组件,使用的是javax.swing.Timer包的Timer类实现,该类的构造方法是:Timer(int delay, ActionListener listener);

该构造方法用于建立一个Timer组件对象,参数listener用于指定一个接收该计时器操作事件的侦听器,指定所要触发的事件;而参数delay用于指定每一次触发事件的时间间隔,单位是毫秒。也就是说,Timer组件会根据用户所指定的delay时间,周期性地触发ActionEvent事件。如果要处理这个事件,就必须实现ActionListener接口类,以及接口类中的actionPerformed()方法。Timer组件类中的主要方法如下。

  • void start():激活Timer组件对象。
  • void stop():停止Timer组件对象。
  • void restart():重新激活Timer组件对象。

下例每隔1000毫秒刷新一次时间。

    public static void main(String[] args) {
        JFrame frame = new JFrame();
        frame.setTitle("Timer测试");
        frame.setSize(300, 300);
 
        Container contentPane = frame.getContentPane();
        JLabel label = new JLabel("标签");
        contentPane.add(label);
 
        // 创建计时器组件Timer,传入两个参数,第一个参数是延迟1000毫秒,即每1000毫秒触发一次事件;第二个参数是事件处理
        Timer timer = new Timer(1000, new ActionListener() {
            @Override
            public void actionPerformed(ActionEvent e) {
                SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
                Date date = new Date();
                // 显示日期时间到JLabel标签中
                label.setText(format.format(date));
            }
        });
        timer.start();
 
        frame.setVisible(true);
        frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
    }

如果某个类实现了ActionListener 接口,因此可以直接设置timer = new Timer (500,this);使用this初始化计时器。当计时器启动后( timer.start()执行后),每隔500ms执行一次实现的ActionListener 接口中的actionPerformed的方法体。

4.4 设置游戏难度

如果使用速度控制游戏难度,则可以把游戏设计成为具有很多个级别,每个级别游戏的运行速度都不一样,类似的代码如下所示:

public void level(){
    if(level==3){
        speed=1; //设置游戏速度为1
    }else{
        speed=2; //设置游戏速度为2
    }
}

4.5 游戏与玩家的交互

游戏与玩家的交互都是通过键盘或鼠标实现的,例如通过上下左右键移动,通过鼠标发出攻击等。

例如,在小球下落时通过键盘控制其左右移动。由于需要监听键盘事件,所以需要实现KeyListener接口。步骤如下:

第一步,先实现KeyListener接口,并将为该面板注册键盘事件监听器。

第二步,设定xpos变量来移动x坐标

第三步,重写keyPressed()方法,当按下键盘按键后响应触发

完整代码如下:

class TestPanel extends JPanel implements Runnable, KeyListener {
    int ypos = 0;
    int xpos = 0;

    public TestPanel() {
        // 创建一个新线程,this就是实现了Runnable接口的实现类
        Thread t = new Thread(this);
        // 启动线程
        t.start();
        // 设定焦点在本面板并作为监听对象
        setFocusable(true);
        addKeyListener(this);
    }

    @Override
    public void paint(Graphics g) {
        g.clearRect(0, 0, this.getWidth(), this.getHeight());// 先清除屏幕上原来的画
        g.setColor(Color.GREEN);// 设置小球的颜色
        g.fillOval(xpos, ypos, 80, 80);// 绘制小球
    }

    @Override
    public void run() {
        while (true) {// 线程中的无限循环
            try {
                Thread.sleep(30);// 休眠30毫秒
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            ypos += 5;// 修改小球左上角的纵坐标
            if (ypos > 300) {// 小球离开窗口后重设左上角的纵坐标
                ypos = 80;
            }
            repaint();// 窗口重绘
        }
    }

    @Override
    public void keyTyped(KeyEvent e) {

    }

    @Override
    public void keyPressed(KeyEvent e) {
        // 当键盘的按键按下后触发该事件
        int keyCode = e.getKeyCode();// 获取按键编号
        if (keyCode == KeyEvent.VK_LEFT) {// 当触发Left键时
            xpos -= 10;
        } else if (keyCode == KeyEvent.VK_RIGHT) {// 当触发Right键时
            xpos += 10;
        }
        repaint();// 重新绘制窗体图像
    }

    @Override
    public void keyReleased(KeyEvent e) {

    }
}

public class Demo {
    public static void main(String[] args) {
        JFrame frame = new JFrame();
        frame.setLocation(200, 200);
        frame.setSize(500, 500);

        TestPanel panel = new TestPanel();
        frame.setContentPane(panel);

        frame.setVisible(true);
        frame.setResizable(false);
        frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
    }
}

4.6 游戏中的碰撞检测

所谓的碰撞就是接触,游戏中常见的碰撞算法有矩形碰撞、圆形碰撞和像素碰撞等。

4.6.1 矩形碰撞

第1种方法:通过检测一个矩形的4个顶点是否在另一个矩形的内部来完成。

通常由x和y坐标以及长度和宽度来确定一个矩形,因此又可以利用这四个参数来确定是否发生了碰撞。

核心的检测处理如下:

    public boolean isCollidingWith(int px, int py) {
        // px和py分别传入的是x坐标和y坐标
        if (px > getX() && px < getX() + getActorWidth() && px > getY() && px < getY() + getActorHeight()) {
            return true;
        }
        return false;
    }

    public boolean isCollidingWith(Actor another) {
        // 判断矩形只要有任何一个点在另一个Actor所表示的矩形范围内,就表示发生了碰撞
        if (isCollidingWith(another.getX(), another.getY()) ||
                isCollidingWith(another.getX() + another.getActorWidth(), another.getY()) ||
                isCollidingWith(another.getX(), another.getY() + another.getActorHeight()) ||
                isCollidingWith(another.getX() + another.getActorWidth(), another.getY() + another.getActorHeight())) {
            return true;
        }
        return false;
    }

完整的代码如下:

public class Actor {
    int x, y, w, h;// 分别是x和y坐标,宽度和高度,构成一个矩形

    public Actor() {
    }

    public Actor(int x, int y, int w, int h) {
        this.x = x;
        this.y = y;
        this.w = w;
        this.h = h;
    }

    public int getX() {
        return x;
    }

    public int getY() {
        return y;
    }

    public int getActorWidth() {
        return w;
    }

    public int getActorHeight() {
        return h;
    }

    @Override
    public String toString() {
        return "Actor{" +
                "x=" + x +
                ", y=" + y +
                ", w=" + w +
                ", h=" + h +
                '}';
    }

    public boolean isCollidingWith(int px, int py) {
        // px和py分别传入的是x坐标和y坐标
        // 等号的情况就是考虑垂直重叠和水平重叠的情况
        // 考虑的情况就是传入的坐标是否在当前的矩形范围内,只要满足下面所有条件就表示传入的坐标在当前矩形范围内,返回true
        if (px >= getX() && px < getX() + getActorWidth() && py >= getY() && py < getY() + getActorHeight()) {
            return true;
        }
        return false;
    }

    // 碰撞检测,发生碰撞返回true,否则返回false
    public boolean isCollidingWith(Actor another) {
        // 判断矩形只要有任何一个点在另一个Actor所表示的矩形范围内,就表示发生了碰撞
        if (isCollidingWith(another.getX(), another.getY()) ||
                isCollidingWith(another.getX() + another.getActorWidth(), another.getY()) ||
                isCollidingWith(another.getX(), another.getY() + another.getActorHeight()) ||
                isCollidingWith(another.getX() + another.getActorWidth(), another.getY() + another.getActorHeight())) {
            return true;
        }
        return false;
    }

    public static void main(String[] args) {
        Actor actor = new Actor(10, 10, 100, 150);
        Actor another = new Actor(20, 50, 100, 150);
        boolean collidingWith = actor.isCollidingWith(another);
        System.out.println(collidingWith);
    }
}

可以通过如下代码移动矩形来进行动态查看:

class TestPanel extends JPanel implements KeyListener {
    private int x1 = 20, y1 = 20, x2 = 160, y2 = 20, width = 100, height = 100;

    public TestPanel() {
        // 设置焦点并且添加键盘事件监听器
        setFocusable(true);
        addKeyListener(this);
    }

    @Override
    public void paint(Graphics g) {
        // 在进行绘制之前,一定要清除之前的图形
        g.clearRect(0, 0, this.getWidth(), this.getHeight());// 先清除屏幕上原来的画
        g.drawRect(x1, y1, width, height);
        g.drawRect(x2, y2, width, height);
    }

    @Override
    public void keyTyped(KeyEvent e) {

    }

    @Override
    public void keyPressed(KeyEvent e) {
        // 处理第一个矩形的移动
        switch (e.getKeyCode()) {
            case KeyEvent.VK_A:// 'A'键
                x1 -= 5;
                break;
            case KeyEvent.VK_D:// 'D'键
                x1 += 5;
                break;
            case KeyEvent.VK_W:// 'W'键
                y1 -= 5;
                break;
            case KeyEvent.VK_S://'S'键
                y1 += 5;
                break;
            case KeyEvent.VK_LEFT://’LEFT'键
                x2 -= 5;
                break;
            case KeyEvent.VK_RIGHT:// 'RIGHT'键
                x2 += 5;
                break;
            case KeyEvent.VK_UP:// 'UP'键
                y2 -= 5;
                break;
            case KeyEvent.VK_DOWN:// 'DOWN'键
                y2 += 5;
                break;
        }
        repaint();// 修改坐标后,重绘图形
        // 判断是否碰撞,输出信息
        Actor actor = new Actor(x1, y1, width, height);
        Actor another = new Actor(x2, y2, width, height);
        System.out.println("是否碰撞:" + (actor.isCollidingWith(another) || another.isCollidingWith(actor)) + "| " + actor + "| " + another);
    }

    @Override
    public void keyReleased(KeyEvent e) {

    }
}

public class Demo {
    public static void main(String[] args) {
        JFrame frame = new JFrame();
        frame.setLocation(200, 200);
        frame.setSize(500, 500);

        TestPanel panel = new TestPanel();
        frame.setContentPane(panel);

        frame.setVisible(true);
        frame.setResizable(false);
        frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
    }
}

第2种方法:从相反的角度考虑,以前是处理什么时候相交,现在处理什么时候不会相交。如两个矩形a和b来判断4条边,假如a矩形在左边,b矩形在右边,那么可以判断左边a矩形的右边界在b矩形的左边界之外,同理,a的上边界需要在b的下边界以外,4条边都判断,则可以知道a矩形是否与b矩形相交。

方法如下:

    /**
     * 判断两个矩形是否会发生碰撞
     *
     * @param ax 矩形a的x坐标
     * @param ay 矩形a的y坐标
     * @param aw 矩形a的宽度
     * @param ah 矩形a的高度
     * @param bx 矩形b的x坐标
     * @param by 矩形b的y坐标
     * @param bw 矩形b的宽度
     * @param bh 矩形b的高度
     * @return 如果发生碰撞则返回true,否则返回false
     */
    public boolean isCollidingWith(int ax, int ay, int aw, int ah, int bx, int by, int bw, int bh) {
        if (ay > by + bh || by > ay + ah || ax > bx + bw || bx > ax + aw) {
            return false;
        }
        return true;
    }

第3种方法:是方法2的变异,我们保存两个矩形的左上和右下两个坐标的坐标值,然后对两个坐标的一个对比就可以得出两个矩形是否相交。

    /**
     * rect1[0]:矩形1左上角x坐标
     * rect1[1]:矩形1左上角y坐标
     * rect1[2]:矩形1右下角x坐标
     * rect1[3]:矩形1右下角y坐标
     * rect2[0]:矩形2左上角x坐标
     * rect2[1]:矩形2左上角y坐标
     * rect2[2]:矩形2右下角x坐标
     * rect2[3]:矩形2右下角y坐标
     *
     * @param rect1 第一个矩形的左上角坐标和右下角坐标数组
     * @param rect2 第二个矩形的左上角坐标和右下角坐标数组
     * @return 如果发生碰撞则返回true,否则返回false
     */
    public static boolean isCollidingWith(int rect1[], int rect2[]) {
        if (rect1[0] > rect2[2]) {
            return false;
        }
        if (rect1[2] < rect2[0]) {
            return false;
        }
        if (rect1[1] > rect2[3]) {
            return false;
        }
        if (rect1[3] < rect2[1]) {
            return false;
        }
        return true;
    }

4.6.2 圆形碰撞

请参考博客:Java游戏中的圆形碰撞检测

4.6.3 像素碰撞

由于游戏中的角色的大小往往是以一一个刚好能够将其包围的矩形区域来表示的,如图4-3所示,虽然两个卡通人物并没有发生真正的碰撞,但是矩形碰撞检查的结果却是它们发生了碰撞。

如果使用像素检查,就通常把精灵的背景颜色设置为相同的颜色而且是最后图片里面很少用到的颜色,然后碰撞检查的时候就只判断两张图片除了背景色外的其他像素区域是否发生了重叠的情况。如图4-4所示,虽然两张图片的矩形发生了碰撞,但是两个卡通人物并没有发生真正的碰撞,这就是像素检查的好处,但缺点是计算复杂,消耗了大量的系统资源,因此一般没有特殊要求,应尽量使用矩形检查碰撞。

4.7 游戏中图像的绘制

虽然Graphics类中提供了很多绘制图形的方法,但通常我们一般都是用其他软件如PS画上图像,保存为图片,再利用Java加载图像。

4.7.1 图像文件的装载

Java所支持的图像文件格式通常有GIF、PNG和JPEG格式,即带有.gif、.jpg、.jpeg等后缀的文件。

Java提供了java.awt.Image包来管理与图像文件有关的信息,提供了用于创建、操纵和观察图像的接口和类。

Toolkit类提供了两个getImage()方法来加载图像:

Image getImage(URL url);
Image getImage(String filename);

Toolkit是一个组件类,获取Toolkit的方法如下:

Toolkit toolkit = Toolkit.getDefaultTolkit();

如果类继承了Component组件类,可以直接通过如下方法获得:

Toolkit toolkit = getToolkit();

在Java中获取一个图像文件,可以调用Toolkit类提供的getlmageO方法。但是getlmage()方法会在调用后马上返回,如果此时马上使用由getlmage(方法获取的Image 对象,但这时Image对象并没有真正装载或者装载完成。因此,我们在使用图像文件时,使用java.awt 包中的MediaTracker跟踪一个 Image对象的装载,可以保证所有图片都加载完毕。

使用MediaTracker的步骤如下:

第一步,实例化MediaTracker,注意要将显示图片的Component对象作为参数传入。

MediaTracker tracker=new MediaTracker(Jpanel1);

第二步,将要装载的Image对象加入到MediaTracker中。

Toolkit toolkit = Toolkit.getDefaultToolkit();
Image pic = toolkit.getImage("abc.jpg");
tracker.addImage(pic, 0);

第三步,调用MediaTracker的checkAll()方法,等待装载过程的结束。

tracker.checkAll(true);

4.7.2 图像文件的显示

getImage()方法只是加载图像文件,如果要显示图像需要Graphics类的drawImage()方法,该方法的重载方法如下:

public abstract boolean drawImage(Image img, int x, int y, ImageObserver observer);
public abstract boolean drawImage(Image img, int x, int y, int width, int height,  ImageObserver observer);
public abstract boolean drawImage(Image img, int x, int y, Color bgcolor, ImageObserver observer);
public abstract boolean drawImage(Image img, int x, int y, int width, int height, Color bgcolor, ImageObserver observer);
public abstract boolean drawImage(Image img,int dx1, int dy1, int dx2, int dy2, int sx1, int sy1, int sx2, int sy2, ImageObserver observer);
public abstract boolean drawImage(Image img, int dx1, int dy1, int dx2, int dy2, int sx1, int sy1, int sx2, int sy2, Color bgcolor, ImageObserver observer);

参数说明:

  • 参数img就是要显示的Image对象。

  • 参数x和y是该图像左上角的坐标值。

  • 参数observer则是一个ImageObserver 接口( intrface ),它用来跟踪图像文件装载是否已经完成的情况,通常我们都将该参数置为this,即传递本对象的引用去实现这个接口。组件可以指定this 作为图像观察者的原因是,Component 类实现了ImageObserver 接口。当图像数据被加载时,它的实现会调用repaint()方法。

  • width和height表示图像显示的高度和宽度。如果实际图像的宽度和高度和设定的这两个参数值不一样则会自动将实际图像进行缩放。

调用Image类中的两个方法就可以分别得到原图的宽度和高度,它们的调用格式如下,与drawImage( )方法一样,我们通常用this作为observer的参数值(但当前类必须继承一个Component类)。

int getwidth (ImageObserver observer)
int getHeight (ImageObserver observer)

示例如下:

class TestPanel extends JPanel {
    String filename;

    public TestPanel(String filename) {
        this.filename = filename;
    }

    @Override
    public void paint(Graphics g) {
        // 获取Image对象,加载图像
        Image img = getToolkit().getImage(filename);
        // 获取图像的宽高
        int w = img.getWidth(this);
        int h = img.getHeight(this);
        // 绘图
        g.drawImage(img, 0, 0, this);
        g.drawImage(img,800,80,w/2,h/2,this);// 缩小一半
    }
}

public class Demo {
    public static void main(String[] args) {
        JFrame frame = new JFrame();
        frame.setLocation(200, 200);
        frame.setSize(500, 500);

        TestPanel panel = new TestPanel("src\\test\\b\\test.jpg");
        frame.setContentPane(panel);

        frame.setVisible(true);
        frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
    }
}

还可以通过javax.imageio.ImageIO类的read()方法获取一幅画像,返回的是BufferedImage对象,BufferedImage是Image的子类,它描述了具有可访问图像数据缓冲区的Image,通过该类即可以实现图片的缩放。

BufferedImage ImageIO.read(Url);

绘制该类产生图像的示例代码:

Image im=ImageIO.read(getClass().getResource("test.jpg"));
g.drawImage(im,0,0,null);// 图像的绘制方式

游戏中的场景图像分为两种,分别叫作卷轴型图像( ribbon )和砖块型图像( tile )。卷轴型图像的特点是内容多、面积大,常常用作远景,例如设计游戏中的蓝天白云图像,一般不与用户交互。砖块型图像通常面积很小,往往由多块这样的图像共同组成游戏的前景,并且作为障碍物与游戏角色进行交互,如推箱子游戏中绘制砖块型图像。

4.7.3 绘制卷轴型图像

卷轴型图像通常要超过程序窗口的尺寸,事实上,在绘制卷轴型图像时往往都需要让其滚动显示以制造移动效果,让图像的不同部分依次从程序窗口中“经过”,就如同坐火车的情形,卷轴型图像好比风景,程序窗口则好比车窗。用程序实现这样的滚动显示效果,则需要将卷轴型图像一段一 段地 显示在程序窗口中,而这又涉及从图像坐标系到程序窗口坐标系的变换问题。

如图4-6所示,左边为程序窗口坐标系,原点在窗口左上角;右边为图像坐标系,原点在卷轴型图像区域的左上角。

图像中的坐标变换可以调用程序窗口的Graphics对象的另一个drawImage()方法实现,该方法有十个参数,如下:

    public abstract boolean drawImage(Image img,
                                      int dx1, int dy1, int dx2, int dy2,
                                      int sx1, int sy1, int sx2, int sy2,
                                      ImageObserver observer);

参数说明:

  • 其中,第1个参数表示源图像;

  • 第2个至第9个参数的含义如图4-6所示。dxl和dyl为目标区域左上角坐标;dx2和dy2为目标区域右下角坐标;sxl和syl为源区域左上角坐标; sx2和sy2为源区域右下角坐标。

如果是水平方向的场景滚动,则dy1和dy2、sy1和sy2的值无需改变,可根据窗口的尺寸设置为固定值。

4.7.4 绘制砖块型图像

砖块型图像是将窗口区域按照砖块大小进行划分成小方格,然后在对应的小方格内绘制图像。

用多个砖块型图像来绘制窗口中的不同区域,需要使用砖块地图( Tile Map)。砖块地图可以简单地使用一个文本文件或者二维数组来保存,记录某个位置显示的图像可以通过图像代号来表示(如推箱子游戏中存储1表示pic1.jpg砖块型图像,存储2表示pic2.jpg砖块型图像,存储0表示不绘制图像等等)。在游戏初始时由程序载人砖块地图文件或者二维数组,并对文件中的信息或者二维数组逐行地进行分析,然后根据不同的图像代号来分别读取不同种类的砖块型图像。

    static byte map[][] = {
            {0, 0, 1, 1, 1, 0, 0, 0},
            {0, 0, 1, 4, 1, 0, 0, 0},
            {0, 0, 1, 9, 1, 1, 1, 1},
            {1, 1, 1, 2, 9, 2, 4, 1},
            {1, 4, 9, 2, 5, 1, 1, 1},
            {1, 1, 1, 1, 2, 1, 0, 0},
            {0, 0, 0, 1, 4, 1, 0, 0},
            {0, 0, 0, 1, 1, 1, 0, 0}};

如走迷宫游戏、连连看游戏、推箱子游戏都采用了砖块型图像技术来实现。

4.8 游戏角色开发

游戏角色就是游戏中可以移动的物体,主要包括玩家控制的角色和电脑控制的角色,如怪物等。

但游戏中需要通过连续绘制多幅静态图像来表示其运动效果。

我们设计一个Sprite类,主要用来实现游戏里面的人物动画和移动的效果,使用Sprite类读取一张张小图片,并且按照一定的顺序存储在数组中,然后在屏幕上显示出其中的一张小图片,如果连续地更换显示的小图片,则在屏幕上表现为一个完整的动画效果。

如Sprite类中的人物动画用4帧状态图片构成,表示移动,如下图:

public class Sprite {
    // x和y坐标
    public int xPos = 0, yPos = 0;
    // 当前帧的ID,也就是第几张图片
    private int playID = 0;
    // Sprite类的图片数组
    private Image pics[] = null;
    // 是否更新绘制Sprite
    boolean facus = true;

    public Sprite() {
        // 加载图片
        pics = new Image[4];
        for (int i = 0; i < 4; i++) {
            pics[i] = Toolkit.getDefaultToolkit().getImage("src\\test\\a\\images\\Right (" + (i + 1) + ").jpg");
        }
    }

    /* 初始化坐标 */
    public void init(int x, int y) {
        this.xPos = x;
        this.yPos = y;
    }

    /* 设置坐标 */
    public void set(int x, int y) {
        this.xPos = x;
        this.yPos = y;
    }

    /* 绘制角色 */
    public void drawSprite(Graphics g, JPanel panel) {
        g.drawImage(pics[playID], xPos, yPos, (ImageObserver) panel);
        playID++;// 下一帧图像
        if (playID == 4) {// 图像放完后,又从第一帧开始
            playID = 0;
        }
    }

    /* 更新角色的坐标点 */
    public void updateSprite() {
        if (facus) {// 每次移动15个像素
            xPos += 15;
        }
        if (xPos > 300) {// 如果达到窗口的右边缘
            xPos = 0;
        }
        System.out.println(xPos);// 打印x坐标
    }
}

测试类,注意传入的图片路径一定要正确,否则无法加载图像,也不会报错

class TestPanel extends JPanel implements Runnable {
    private Sprite player;

    public TestPanel() {
        // 创建角色
        player = new Sprite();
        player.init(0, 0);
        Thread thread = new Thread(this);// 新建一个线程
        thread.start();// 启动线程
    }

    @Override
    public void paint(Graphics g) {
        // 清除屏幕,擦除掉原来画的东西
        g.clearRect(0, 0, getWidth(), getHeight());
        player.drawSprite(g, this);// 绘制角色
    }

    @Override
    public void run() {
        // 线程中无限循环
        while (true) {
            player.updateSprite();// 更新角色的x和y坐标
            try {
                Thread.sleep(500);// 休眠500毫秒
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            repaint();// 窗口重绘
        }
    }
}

public class Demo {
    public static void main(String[] args) {
        JFrame frame = new JFrame();
        frame.setLocation(200, 200);
        frame.setSize(500, 500);

        TestPanel panel = new TestPanel();
        frame.setContentPane(panel);

        frame.setVisible(true);
        frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
    }
}

效果如图:

在游戏中人物角色通过键盘事件中用户按键来决定方向,从而更新角色的x和y坐标。

4.9 游戏声音效果的设定

游戏中的声音效果一般分为两类,分别是动作音效和场景音乐。前者用于动作的配音,以便增强游戏角色的真实感后者用于;烘托游戏气氛,通过为不同的背景配备相对应的音乐来表达特定的情感。
Java提供了javax.sound.sampled包来对声音进行处理和播放,支持的格式有:AIFF、AU和WAV三种。

同图像的显示一样,需要先将声音文件装载进来,然后播放,步骤如下:

第一步,新建一个文件对象获取WAV声音文件数据。

File file = new File("sound.wav");

第二步,把WA文件转换为音频输入流

AudioInputStream stream = AudioSystem.getAudioInputStream(file);

第三步,获取音频格式

AudioFormat format = stream.getFormat();

第四步,设置音频行信息

DataLine.Info info = new DataLine.Info(Clip.class, format);

第五步,建立音频行

Clip clip = (Clip)AudioSystem.getLine(info);

第六步,将音频数据流读入音频行

clip.open(stream);

第七步,播放音频行

clip.start();

处理声音数据输人的流类叫作AudiolnputStream, 它是具有指定音频格式和长度的输人流。除此之外,还需要使用AudioSystem类,该类用于充当取样音频系统资源的人口点,提供许多在不同格式间转换音频数据的方法,以及在音频文件和流之间进行转换的方法。

AudioFormat类是在声音流中指定特定数据安排的类。通过检查以音频格式存储的信息,可以发现在二进制声音数据中解释位的方式。其中涉及的类包括如下几个。

  • Line:Line接口表示单声道或多声道音频供给。
  • DataLine:包括一些音频传输控制方法,这些方法可以启动、停止、消耗和刷新通过数据行传人的音频数据。
  • DataLine.Info:提供音频数据行的信息。包括受数据行支持的音频格式、其内部缓冲区的最小和最大值等。
  • Clip:接口表示特殊种类的数据行,该数据行的音频数据可以在回放前加载,而不是实时流出。音频剪辑的回放可以使用start0和stop0方法开始和终止。这些方法不重新设置介质的位置,start()方法的功能是从回放最后停止的位置继续回放。

示例如下:

封装一个声音播放类SoundPlayer.java

/**
 * 声音播放类
 */
public class SoundPlayer {
    public File file;
    public AudioInputStream stream;
    public AudioFormat format;
    DataLine.Info info;
    Clip clip;

    /**
     * 加载声音文件
     *
     * @param filePath 声音文件的路径
     */
    public void loadSound(String filePath) {
        file = new File(filePath);
        try {
            stream = AudioSystem.getAudioInputStream(file);
        } catch (UnsupportedAudioFileException | IOException e) {
            e.printStackTrace();
        }
        format = stream.getFormat();
    }

    /**
     * 播放声音
     */
    public void playSound() {
        info = new DataLine.Info(Clip.class, format);
        try {
            clip = (Clip) AudioSystem.getLine(info);
            clip.open(stream);
        } catch (LineUnavailableException | IOException e) {
            e.printStackTrace();
        }
        clip.start();
    }

}

为角色动作添加动作音乐,在用户按键盘的空格键时播放音效,代码如下:

おすすめ

転載: blog.csdn.net/cnds123321/article/details/117751459