ZetCode

动画

最后修改于 2023 年 1 月 10 日

在 Java 2D 游戏教程的这一部分,我们将学习动画。

动画

动画 是快速显示一系列图像,从而产生运动的错觉。我们将为我们的面板(Board)上的一个星星制作动画。我们将以三种基本方式实现运动。我们将使用 Swing 计时器、标准实用计时器和线程。

动画是游戏编程中一个复杂的主题。Java 游戏需要在具有不同硬件规格的多个操作系统上运行。线程提供了最精确的计时解决方案。然而,对于我们简单的 2D 游戏,其他两种选项也可以考虑。

Swing 计时器

在第一个示例中,我们将使用 Swing 计时器来创建动画。这是在 Java 游戏中为对象设置动画的最简单但也是效率最低的方式。

SwingTimerEx.java
package com.zetcode;

import java.awt.EventQueue;
import javax.swing.JFrame;

public class SwingTimerEx extends JFrame {

    public SwingTimerEx() {

        initUI();
    }

    private void initUI() {

        add(new Board());

        setResizable(false);
        pack();

        setTitle("Star");
        setLocationRelativeTo(null);
        setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
    }

    public static void main(String[] args) {

        EventQueue.invokeLater(() -> {
            SwingTimerEx ex = new SwingTimerEx();
            ex.setVisible(true);
        });
    }
}

这是代码示例的主类。

setResizable(false);
pack();

setResizable() 方法设置窗口是否可调整大小。pack() 方法会使该窗口的大小适应其子组件的首选大小和布局。请注意,调用这两个方法的顺序很重要。(setResizable() 在某些平台上会改变窗口的边距;在 pack() 方法之后调用此方法可能会导致不正确的结果——星星将无法精确地进入窗口的右下角边框。)

Board.java
package com.zetcode;

import java.awt.Color;
import java.awt.Dimension;
import java.awt.Graphics;
import java.awt.Image;
import java.awt.Toolkit;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import javax.swing.ImageIcon;
import javax.swing.JPanel;
import javax.swing.Timer;

public class Board extends JPanel
        implements ActionListener {

    private final int B_WIDTH = 350;
    private final int B_HEIGHT = 350;
    private final int INITIAL_X = -40;
    private final int INITIAL_Y = -40;
    private final int DELAY = 25;

    private Image star;
    private Timer timer;
    private int x, y;

    public Board() {

        initBoard();
    }

    private void loadImage() {

        ImageIcon ii = new ImageIcon("src/resources/star.png");
        star = ii.getImage();
    }

    private void initBoard() {

        setBackground(Color.BLACK);
        setPreferredSize(new Dimension(B_WIDTH, B_HEIGHT));

        loadImage();

        x = INITIAL_X;
        y = INITIAL_Y;

        timer = new Timer(DELAY, this);
        timer.start();
    }

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

        drawStar(g);
    }

    private void drawStar(Graphics g) {

        g.drawImage(star, x, y, this);
        Toolkit.getDefaultToolkit().sync();
    }

    @Override
    public void actionPerformed(ActionEvent e) {

        x += 1;
        y += 1;

        if (y > B_HEIGHT) {

            y = INITIAL_Y;
            x = INITIAL_X;
        }

        repaint();
    }
}

Board 类中,我们将一个星星从左上角移动到右下角。

private final int B_WIDTH = 350;
private final int B_HEIGHT = 350;
private final int INITIAL_X = -40;
private final int INITIAL_Y = -40;
private final int DELAY = 25;

定义了五个常量。前两个常量是面板的宽度和高度。第三个和第四个是星星的初始坐标。最后一个常量决定了动画的速度。

private void loadImage() {

    ImageIcon ii = new ImageIcon("src/resources/star.png");
    star = ii.getImage();
}

loadImage() 方法中,我们创建了一个 ImageIcon 类的实例。图像位于项目目录中。getImage() 方法将从此类返回 Image 对象。该对象将被绘制在面板上。

timer = new Timer(DELAY, this);
timer.start();

在这里,我们创建了一个 Swing Timer 类并调用其 start() 方法。每隔 DELAY 毫秒,计时器将调用 actionPerformed() 方法。为了使用 actionPerformed() 方法,我们必须实现 ActionListener 接口。

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

    drawStar(g);
}

自定义绘制在 paintComponent() 方法中完成。请注意,我们也调用了其父类的 paintComponent() 方法。实际的绘制委托给了 drawStar() 方法。

private void drawStar(Graphics g) {

    g.drawImage(star, x, y, this);
    Toolkit.getDefaultToolkit().sync();
}

在 drawStar() 方法中,我们使用 drawImage() 方法在窗口上绘制图像。Toolkit.getDefaultToolkit().sync() 会同步在缓冲图形事件的系统上的绘制。没有这一行,动画在 Linux 上可能不够流畅。

@Override
public void actionPerformed(ActionEvent e) {

    x += 1;
    y += 1;

    if (y > B_HEIGHT) {

        y = INITIAL_Y;
        x = INITIAL_X;
    }

    repaint();
}

actionPerformed() 方法由计时器反复调用。在该方法内部,我们增加星星对象的 x 和 y 值。然后我们调用 repaint() 方法,该方法将导致调用 paintComponent()。这样,我们就定期重绘了 Board,从而实现了动画。

Star
图:星形

实用计时器

这与之前的方法非常相似。我们使用 java.util.Timer 而不是 javax.Swing.Timer。对于 Java Swing 游戏,这种方式更准确。

UtilityTimerEx.java
package com.zetcode;

import java.awt.EventQueue;
import javax.swing.JFrame;

public class UtilityTimerEx extends JFrame {

    public UtilityTimerEx() {

        initUI();
    }

    private void initUI() {

        add(new Board());

        setResizable(false);
        pack();

        setTitle("Star");
        setLocationRelativeTo(null);
        setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
    }

    public static void main(String[] args) {

        EventQueue.invokeLater(() -> {
            JFrame ex = new UtilityTimerEx();
            ex.setVisible(true);
        });
    }
}

这是主类。

Board.java
package com.zetcode;

import java.awt.Color;
import java.awt.Dimension;
import java.awt.Graphics;
import java.awt.Image;
import java.awt.Toolkit;
import java.util.Timer;
import java.util.TimerTask;
import javax.swing.ImageIcon;
import javax.swing.JPanel;

public class Board extends JPanel  {

    private final int B_WIDTH = 350;
    private final int B_HEIGHT = 350;
    private final int INITIAL_X = -40;
    private final int INITIAL_Y = -40;
    private final int INITIAL_DELAY = 100;
    private final int PERIOD_INTERVAL = 25;

    private Image star;
    private Timer timer;
    private int x, y;

    public Board() {

        initBoard();
    }

    private void loadImage() {

        ImageIcon ii = new ImageIcon("src/resources/star.png");
        star = ii.getImage();
    }

    private void initBoard() {

        setBackground(Color.BLACK);
        setPreferredSize(new Dimension(B_WIDTH, B_HEIGHT));

        loadImage();

        x = INITIAL_X;
        y = INITIAL_Y;

        timer = new Timer();
        timer.scheduleAtFixedRate(new ScheduleTask(),
                INITIAL_DELAY, PERIOD_INTERVAL);
    }

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

        drawStar(g);
    }

    private void drawStar(Graphics g) {

        g.drawImage(star, x, y, this);
        Toolkit.getDefaultToolkit().sync();
    }

    private class ScheduleTask extends TimerTask {

        @Override
        public void run() {

            x += 1;
            y += 1;

            if (y > B_HEIGHT) {
                y = INITIAL_Y;
                x = INITIAL_X;
            }

            repaint();
        }
    }
}

在这个示例中,计时器将定期调用 ScheduleTask 类的 run() 方法。

timer = new Timer();
timer.scheduleAtFixedRate(new ScheduleTask(),
        INITIAL_DELAY, PERIOD_INTERVAL);

在这里,我们创建了一个计时器并安排了一个具有特定时间间隔的任务。有一个初始延迟。

@Override
public void run() {
    ...
}

每 10 毫秒,计时器将调用此 run() 方法。

线程

使用线程为对象设置动画是最有效和最准确的动画方式。

ThreadAnimationEx.java
package com.zetcode;

import java.awt.EventQueue;
import javax.swing.JFrame;

public class ThreadAnimationEx extends JFrame {

    public ThreadAnimationEx() {

        initUI();
    }

    private void initUI() {

        add(new Board());

        setResizable(false);
        pack();

        setTitle("Star");
        setLocationRelativeTo(null);
        setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
    }

    public static void main(String[] args) {

        EventQueue.invokeLater(() -> {
            JFrame ex = new ThreadAnimationEx();
            ex.setVisible(true);
        });
    }
}

这是主类。

Board.java
package com.zetcode;

import java.awt.Color;
import java.awt.Dimension;
import java.awt.Graphics;
import java.awt.Image;
import java.awt.Toolkit;
import javax.swing.ImageIcon;
import javax.swing.JOptionPane;
import javax.swing.JPanel;

public class Board extends JPanel
        implements Runnable {

    private final int B_WIDTH = 350;
    private final int B_HEIGHT = 350;
    private final int INITIAL_X = -40;
    private final int INITIAL_Y = -40;
    private final int DELAY = 25;

    private Image star;
    private Thread animator;
    private int x, y;

    public Board() {

        initBoard();
    }

    private void loadImage() {

        ImageIcon ii = new ImageIcon("src/resources/star.png");
        star = ii.getImage();
    }

    private void initBoard() {

        setBackground(Color.BLACK);
        setPreferredSize(new Dimension(B_WIDTH, B_HEIGHT));

        loadImage();

        x = INITIAL_X;
        y = INITIAL_Y;
    }

    @Override
    public void addNotify() {
        super.addNotify();

        animator = new Thread(this);
        animator.start();
    }

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

        drawStar(g);
    }

    private void drawStar(Graphics g) {

        g.drawImage(star, x, y, this);
        Toolkit.getDefaultToolkit().sync();
    }

    private void cycle() {

        x += 1;
        y += 1;

        if (y > B_HEIGHT) {

            y = INITIAL_Y;
            x = INITIAL_X;
        }
    }

    @Override
    public void run() {

        long beforeTime, timeDiff, sleep;

        beforeTime = System.currentTimeMillis();

        while (true) {

            cycle();
            repaint();

            timeDiff = System.currentTimeMillis() - beforeTime;
            sleep = DELAY - timeDiff;

            if (sleep < 0) {
                sleep = 2;
            }

            try {
                Thread.sleep(sleep);
            } catch (InterruptedException e) {

                String msg = String.format("Thread interrupted: %s", e.getMessage());

                JOptionPane.showMessageDialog(this, msg, "Error",
                    JOptionPane.ERROR_MESSAGE);
            }

            beforeTime = System.currentTimeMillis();
        }
    }
}

在前面的示例中,我们在特定时间间隔执行任务。在此示例中,动画将在线程内进行。run() 方法仅被调用一次。这就是为什么我们在该方法中有一个 while 循环。从该方法中,我们调用 cycle()repaint() 方法。

@Override
public void addNotify() {
    super.addNotify();

    animator = new Thread(this);
    animator.start();
}

addNotify() 方法在我们将 JPanel 添加到 JFrame 组件之后被调用。此方法通常用于各种初始化任务。

我们希望我们的游戏流畅运行,速度恒定。因此,我们计算系统时间。

timeDiff = System.currentTimeMillis() - beforeTime;
sleep = DELAY - timeDiff;

cyclerepaint 方法在不同的 while 循环中可能花费的时间不同。我们计算这两个方法运行的时间,并将其从 DELAY 常量中减去。这样,我们希望确保每个 while 循环都以恒定的时间运行。在我们的例子中,每个循环是 DELAY 毫秒。

Java 2D 游戏教程的这一部分介绍了动画。