动画
最后修改于 2023 年 1 月 10 日
在 Java 2D 游戏教程的这一部分,我们将学习动画。
动画
动画 是快速显示一系列图像,从而产生运动的错觉。我们将为我们的面板(Board)上的一个星星制作动画。我们将以三种基本方式实现运动。我们将使用 Swing 计时器、标准实用计时器和线程。
动画是游戏编程中一个复杂的主题。Java 游戏需要在具有不同硬件规格的多个操作系统上运行。线程提供了最精确的计时解决方案。然而,对于我们简单的 2D 游戏,其他两种选项也可以考虑。
Swing 计时器
在第一个示例中,我们将使用 Swing 计时器来创建动画。这是在 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()
方法之后调用此方法可能会导致不正确的结果——星星将无法精确地进入窗口的右下角边框。)
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
,从而实现了动画。

实用计时器
这与之前的方法非常相似。我们使用 java.util.Timer
而不是 javax.Swing.Timer
。对于 Java Swing 游戏,这种方式更准确。
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); }); } }
这是主类。
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()
方法。
线程
使用线程为对象设置动画是最有效和最准确的动画方式。
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); }); } }
这是主类。
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;
cycle
和 repaint
方法在不同的 while 循环中可能花费的时间不同。我们计算这两个方法运行的时间,并将其从 DELAY
常量中减去。这样,我们希望确保每个 while 循环都以恒定的时间运行。在我们的例子中,每个循环是 DELAY
毫秒。
Java 2D 游戏教程的这一部分介绍了动画。