【IT168 技术文章】在二维(2D)动画中,通常需要按预定义的模式(有时称为 控制路径)在一个 2D 区域内移动对象。这种动画需要解决两个问题:
如何指定对象要遵循的控制路径。
如何沿着所选的路径移动对象。
在本文中我们将为您展示如何用无损图像、Swing 技术和基于 Java 的动画引擎解决这些问题。我们将首先绘制所需要的动画对象轨道,然后用动画引擎驱动对象沿着定义的控制路径运动。
可以容易创建和处理无损图像,而且可以根据需要对使用它们的技术进行细致的调节。我们将利用一个示例动画序列,介绍如何用不同的颜色集创建复杂的运动序列。我们还将介绍如何处理图像以提取出所需要的控制路径、将控制路径与背景图像分层、为动画序列创建对象(Swing GUI 组件),并驱动这些对象沿着定义的控制路径运动以完成动画过程。
什么是无损?
无损图像(lossless image)是永久保留了所有图像像素的图像。这种图像必须能够存储为或者恢复成与原件完全一样的复制品。
可以使用不同的应用程序创始无损图像,包括 Microsoft Paint、Jasc Paint Shop Pro 和一些定制的应用程序。可以将这些图像存储到文件中,也可以只在内存创建它们。图像必须是无压缩的,或者是使用无损压缩算法如 zip 压缩进行压缩的。典型的无损图像格式包括 Microsoft 的 Bitmap (BMP) 和 Portable Network Graphics (PNG) 格式。有损压缩算法,比如通常用于 GIF(Graphics Interchange Format)和 JPEG(Joint Photographic Experts Group) 文件的压缩算法,不适用于本文所描述的动画技术。
完全是控制问题
控制路径 的最一般化的定义是通过任意 n 维空间时,在特定位置和时间所要采取的行为。我们将控制路径定义为一个或者多个对象穿过一个 2D 空间时所采取的路径。通过将对象的位置映射到该位置的行为来表示控制路径。然后程序遍历所定义的对象、在映射中查找对象在该位置上的行为、并让对象执行所指定的行动。对所有控制路径—— 除去最简单的—— 在代码中建立这样一个映射都是耗费时间和容易出错的,因此使用一个绘图程序更合适。
控制路径可以是 不随时间变化的(time invariant),在这种情况下是静态的,也可以是 随时间变化的(time variable),在这种情况下是动态的。如果无损图像包含在一个图像文件中,那么它就是不随时间变化的,或者说是静态的。如果无损图像是包含在 RAM 中并直接使用的,那么它就是随时间变化的,或者说是动态的。在本文中我们讨论的是静态控制路径。使用正确的编辑程序,可以更容易地生成静态图像,尽管所定义的行为类型也会在某种程度上影响这个过程。
让我们度过一个狂热的夜晚!
学习动画的一个好方法是自己动手实践。我们将在本文其余部分使用一个动画的例子来阐明所讨论的概念。我们的例子是一个动画的火灾逃生序列,我们将生成控制路径以表示几个人物的逃生路径。我们将使用图 1 中的部分平面图作为背景图像。可以在 图 6中看到完整的背景图像。
图 1. 背景图像的一部分

我们可以用一个数值数组生成控制映射。用一个图像代替数组(如图 2 所示)使我们可以用颜色值来表示每一个位置的行为。每一种颜色值的大小(颜色位数)取决于图像格式。图 2 展示了火灾逃生序列的控制路径。
图 2. 部分控制路径

为了看到控制路径图像与动画背景的对应关系,我们可以将控制图像覆盖到背景图像上,如图 3 所示。
图 3. 使用分层透明度结合起来的图像

让世界充满色彩
生成一个图像后,就可容易地将它转换为所需要的映射。我们只要遍历图像的颜色并为每种颜色值指定一种行为。比如我们可以使用白色 ——它通常是全为 1 的值——表示无映射或者默认行为。黑色——通常是零值——可以用于表示自定义的行为。如果是根据我们的图像映射的,当对象遇到一个有同样行为(即同一种颜色,如黑色)的位置时,它就会继续沿着由这个位置定义的方向运行。如果这个位置不是同样的行为,那么它就会找到与它有同样行为的相邻位置,但是不会走回头路。
我们可以用不同的颜色表示其他行为。没有定义的颜色值将会被忽略。因此,背景中的像素(如 图 3中的浅灰色)会被忽略。
清单 1 显示如何完成映射。先针对特定颜色值扫描该图像,然后用每一个颜色像素的位置定义该位置在控制状态映射中的控制状态。在逃生的例子中,用不同的 STATE_xxx 常量定义了六种行为。
清单 1. 处理控制路径图像
2 :
3 public final static int STATE_UNKNOWN = -1;
4 public final static int STATE_NONE = 0;
5 public final static int STATE_HALLWAY = 1;
6 public final static int STATE_INTERSECTION = 2;
7 public final static int STATE_HINT = 3;
8 public final static int STATE_START = 4;
9 public final static int STATE_EXIT = 5;
10 :
11 /** Process the control image */
12 void processControl(Image img, int x, int y, int w, int h)
13 {
14 int pmap[] = new int[w * h];
15 PixelGrabber pg = new PixelGrabber(img, x, y, w, h, pmap, 0, w);
16 try {
17 pg.grabPixels();
18 if ((pg.getStatus() & ImageObserver.ABORT) != 0) {
19 System.err.println("image fetch error");
20 }
21 else {
22 Integer none = new Integer(STATE_NONE);
23 Integer hall = new Integer(STATE_HALLWAY);
24 Integer start = new Integer(STATE_START);
25 Integer exit = new Integer(STATE_EXIT);
26 Integer hint = new Integer(STATE_HINT);
27 Integer inter = new Integer(STATE_INTERSECTION);
28 // for each position
29 for (int i = 0; i < pmap.length; i++) {
30 int red = (pmap[i] >> 16) & 0xff;
31 int green = (pmap[i] >> 8) & 0xff;
32 int blue = (pmap[i] ) & 0xff;
33 if (red == 255 && green == 255 && blue == 255)
34 ; // don't bother to add NONE to map
35 else if (red == 0 && green == 0 && blue == 0)
36 map.put(new Integer(i), hall);
37 else if (red == 0 && green == 255 && blue == 0)
38 map.put(new Integer(i), start);
39 else if (red == 200 && green == 0 && blue == 0)
40 map.put(new Integer(i), exit);
41 else if (red == 255 && green == 0 && blue == 0)
42 map.put(new Integer(i), hint);
43 else if (red == 0 && green == 0 && blue == 255)
44 map.put(new Integer(i), inter);
45 }
46 }
47 }
48 catch (InterruptedException e) {
49 System.err.println("image processing interrupted");
50 }
51 }
52
尽管在清单 1 中使用了具体的颜色值(即红色是 200,绿色是 0,蓝色是 0),但是我们可以容易地改进代码,让它支持颜色范围。使用颜色范围降低了在绘图程序中使用的颜色选择的精度,因而更容易创建路径图像。
使用更多的颜色使我们可以定义更多状态,还可以描述更复杂的行为。例如,可以使用 RGB 方案中的不同颜色段创建重叠的控制路径。如果上述每一种状态都是由一种颜色的不同深浅而不是由不同颜色编码的,那么三个独立的控制路径可以彼此重叠。当然,使用一种颜色的不同深浅使得区分不同行为的细微颜色差别变得困难了。大多数图像编辑程序可显示出所选像素的准确颜色值,这使这个问题没那么严重了。
还可以定义三个以上的控制路径。如果通过一个位掩码(bitmask)访问每一个颜色值,那么将只受图像格式的位数限制(通常是 24,如果使用 alpha 值则为 32)。使用精确的位的路径比使用颜色段的路径更复杂,但是这是可以做到的。您需要有一个可以合并各个图像控制路径的程序或者使用一个图像并在上面添加绘制。如果不需要支持重叠的路径(即在一个位置上有多种状态),那么在一个位置上可以有 2^24(或者 2^32)种状态。还可混合这两种方法。例如,通过位掩码使用红色段,而用绿色段和蓝色段表示其他状态。
图 4 显示了我们的逃生模拟所使用的完整控制路径。注意多种颜色的使用,以及如何用颜色表示不同位置上的不同行为。
图 4. 完整的控制路径
图 5 局部放大了控制路径以看得更清楚。
图 5. 控制路径局部细节
逃生!
在定义了状态映射后,就可以开始在 2D 空间中移动对象了。这个示例逃生应用程序让可移动对象成为 Entity 类的实例。定义了两个主要子类: Person 和 Alarm 。 Person 可以移动,而 Alarm 是静止的。清单 2 定义了 Entity 接口。
清单 2. Entity 接口
2 void addToPanel(JPanel panel, boolean shared);
3
4 void updateTick();
5 }
6
addToPanel() 方法创建一个或者多个 Swing 组件来表示对象并将它们添加到所提供的面板中,这些组件一般是带有图标集的 JLabel 。面板通常是 2D 空间的实现。它的背景显示动画背景。
updateTick() 方法使对象在动画的每一周期活动。 Alarm 对象改变它们的颜色以创建闪烁的效果。 Person 对象则移动。
Alarm 对象是简单的闪烁对象,其实现如清单 3 所示。
清单 3. Alarm.updateTick: 闪烁
2 if (++tick % CYCLE == 0) {
3 opaque = !opaque;
4 }
5 }
6
Person 对象比 Alarm 复杂。它们沿着定义的控制路径移动,如清单 4 所示。
清单 4. Person.updateTick: 沿着路径移动
2 public synchronized void updateTick() {
3 tick++;
4 Integer tock = stops.get(new Integer(tick));
5 if (tock != null) { // adjust startTime if requested
6 startTick = tick + tock.intValue();
7 }
8 if (tick < startTick) return; // not my time yet
9 if (isAtExit()) return;
10 // Process individual movement
11 Point2D location = getPosition();
12 int x = (int)location.getX();
13 int y = (int)location.getY();
14 switch (manager.stateAt(x, y)) {
15 case BuildingManager.STATE_EXIT:
16 atExit = true;
17 break;
18 case BuildingManager.STATE_START:
19 case BuildingManager.STATE_INTERSECTION:
20 // process any hints
21 if (manager.stateAt(x - 1, y) ==
22 BuildingManager.STATE_HINT)
23 setDirection(Person.DIR_WEST);
24 else if (manager.stateAt(x + 1, y) ==
25 BuildingManager.STATE_HINT)
26 setDirection(Person.DIR_EAST);
27 else if (manager.stateAt(x, y + 1) ==
28 BuildingManager.STATE_HINT)
29 setDirection(Person.DIR_SOUTH);
30 else if (manager.stateAt(x, y - 1) ==
31 BuildingManager.STATE_HINT)
32 setDirection(Person.DIR_NORTH);
33 // no hints, select a direction
34 if (getDirection() == DIR_NONE) {
35 if (manager.stateAt(x - 1, y) !=
36 BuildingManager.STATE_NONE)
37 setDirection(Person.DIR_WEST);
38 else if (manager.stateAt(x + 1, y) !=
39 BuildingManager.STATE_NONE)
40 setDirection(Person.DIR_EAST);
41 else if (manager.stateAt(x, y + 1) !=
42 BuildingManager.STATE_NONE)
43 setDirection(Person.DIR_SOUTH);
44 else if (manager.stateAt(x, y - 1) !=
45 BuildingManager.STATE_NONE)
46 setDirection(Person.DIR_NORTH);
47 }
48 case BuildingManager.STATE_HALLWAY:
49 case BuildingManager.STATE_HINT:
50 // effect motion in selected direction
51 int tempX = x;
52 int tempY = y;
53 switch (getDirection()) {
54 case DIR_EAST: x += 1; break;
55 case DIR_WEST: x -= 1; break;
56 case DIR_NORTH: y -= 1; break;
57 case DIR_SOUTH: y += 1; break;
58 }
59 int check = manager.stateAt(x, y);
60 if (check == manager.STATE_UNKNOWN ||
61 check == manager.STATE_NONE) {
62 // went off the path, backup
63 x = tempX;
64 y = tempY;
65 if (getDirection() == DIR_EAST ||
66 getDirection() == DIR_WEST) {
67 if (manager.stateAt(x, y + 1) !=
68 BuildingManager.STATE_NONE &&
69 manager.stateAt(x, y + 1) !=
70 BuildingManager.STATE_UNKNOWN) {
71 setDirection(Person.DIR_SOUTH);
72 y += 1;
73 }
74 else {
75 // Only direction not checked is north
76 setDirection(Person.DIR_NORTH);
77 y -= 1;
78 }
79 }
80 else {
81 if (manager.stateAt(x + 1, y) !=
82 BuildingManager.STATE_NONE &&
83 manager.stateAt(x + 1, y) !=
84 BuildingManager.STATE_UNKNOWN) {
85 setDirection(Person.DIR_EAST);
86 x += 1;
87 }
88 else {
89 // Only direction not checked is south
90 setDirection(Person.DIR_WEST);
91 x -= 1;
92 }
93 }
94 }
95 setNextPoint(new Point(x, y));
96 }
97 }
98
这个相当复杂的方法主要是检查当前位置的映射。然后选择要去的非常好的位置。它试图尽可能地沿同一个方向走。注意 hints是标志,提供了优先选择的方向。它们通常用于开始位置和交叉路口。
Person 可以过早地(也就是在达到路径终点之前)停止,也可以设定为经过固定的时间后才开始移动。 Person 对象还可以留下逐渐消失的图像痕迹(或者历史)以描绘出它们的运动,如图 6 所示。
图 6. 一个带有历史痕迹的 person 移动

清单 5 显示了用于移动实体的逻辑。这个过程在每个动画周期中执行一次。
清单 5. 移动所有实体
2 public void moveEntities() {
3 // update (move) the people
4 for (Iterator iter = people.iterator(); iter.hasNext();) {
5 Object next = iter.next();
6 if (next instanceof Person) {
7 ((Person)next).updateTick();
8 }
9 }
10 // update the other entities
11 for (Iterator iter = entities.iterator(); iter.hasNext();) {
12 Object next = iter.next();
13 if (next instanceof Entity) {
14 ((Entity)next).updateTick();
15 }
16 }
17 }
18
动画中的每个新帧(frame)都是由清单 6 所显示的过程创建的。注意实体将它们自己添加到帧中。
清单 6. 在当前位置添加实体
2 public void prepareNextFrame(boolean update, boolean shared) {
3 setBackground(Color.black);
4 if (update) {
5 manager.moveEntities();
6 }
7 mainPanel.setBounds(getBounds());
8 mainPanel.removeAll();
9 // add the people
10 for (Iterator iter = manager.getPeople().iterator();
11 iter.hasNext();) {
12 Person person = (Person)iter.next();
13 person.addToPanel(mainPanel, shared);
14 }
15 // add the entities
16 for (Iterator iter = manager.getEntities().iterator();
17 iter.hasNext();) {
18 Entity entity = (Entity)iter.next();
19 entity.addToPanel(mainPanel, shared);
20 }
21 }
22
我们用清单 7 中的代码实现连续的动画。 delay 值控制动画运行的快慢以及它占用 CPU 时间的多少。
清单 7. 动画周期
2 TimerTask task = new TimerTask() {
3 public void run() {
4 SwingUtilities.invokeLater(new Runnable() {
5 public void run() {
6 prepareNextFrame();
7 repaint();
8 if (manager.getPeople().size() == 0) {
9 cancel();
10 }
11 }
12 });
13 }
14 };
15 (new Timer(true)).scheduleAtFixedRate(task, 0, delay);
16 }
17
下面是我们动画序列的几个屏幕快照。图 7 显示了在行动之前的逃生模拟(完成了全部过程的大约 10%)。
图 7. 刚开始时的逃生序列
图 8 显示在完成了大约 50% 时的动画序列。
图 8. 数次更新后的逃生序列
图 9 显示接近完成时的序列(尽管序列实际上是无限循环运行的)。
图 9. 接近完成时的逃生序列
在图 10 中可以看到实体是如何沿着控制路径移动的,它应当可以让您更好地理解运行是如何实现的。
图 10. 实体沿着控制路径移动
类 BuildingViewer 创建让对象在其中移动的容器。 paintChldren() 方法首先绘制背景图像,然后是报警消息,最后是表示不同实体的子组件。
清单 8. BuildingViewer.paintChildren
2 private static final Color evacColor = new Color(255, 0, 0, 128);
3 :
4 /** draw the background, massage and entities */
5 public void paintChildren(Graphics g) {
6 paintCount++;
7 if (background != null) {
8 Graphics g2 = g.create();
9 try {
10 // draw the background
11 g2.drawImage(background.getImage(),
12 (int)getLocation().getX(),
13 (int)getLocation().getY(),
14 (int)getLocation().getX() + getWidth(),
15 (int)getLocation().getY() + getHeight(),
16 0, 0,
17 background.getIconWidth(),
18 background.getIconHeight(),
19 Color.black, null);
20 // draw the alert message (if any)
21 if (alertMessage != null) {
22 if (paintCount % alertPeriod >= (alertPeriod / 2)) {
23 Font f = g2.getFont();
24 Font f2 = f.deriveFont((float)alertSize);
25 FontMetrics fm = Toolkit.getDefaultToolkit().
26 getFontMetrics(f2);
27 int fHeight = fm.getHeight(),
28 fAscent = fm.getAscent();
29 int sWidth = fm.stringWidth(alertMessage);
30 g2.setFont(f2);
31 Graphics2D g2d = (Graphics2D)g2;
32 g2d.setStroke(new BasicStroke(10));
33 g2.setColor(evacColor);
34 g2.drawString(alertMessage,
35 (getWidth() - sWidth) / 2,
36 (getHeight() - fHeight) / 2 +
37 Ascent);
38 }
39 }
40 } finally {
41 g2.dispose();
42 }
43 }
44 super.paintChildren(g);
45 }
46
要创建有效的动画,需要移动的对象。清单 9 显示的代码根据所提供的输入创建一系列同样类型(即残疾的、非残疾的、消防员等)的 Person 实体。
清单 9. 创建相同类型的实体
2 protected static void initLoop(BuildingManager manager, ImageIcon icon,
3 int[] locs, String[] names,
4 int[] starts, int[] appear, int[][][] stops)
5 {
6 LinkedList startPts = (LinkedList)manager.
7 getAvailableStartingPoints();
8 // for all specified locations - create a Person
9 for (int i = 0; i < locs.length; i++) {
10 JLabel label = new JLabel(names[i], icon, JLabel.CENTER);
11 label.setFont(new Font(label.getFont().getName(),
12 label.getFont().getStyle(), 20));
13 Person person = new Person(manager, label,
14 (Point2D)startPts.get(
15 Math.min(startPts.size() - 1, locs[i])),
16 starts[i]);
17 person.setAppearTick(appear[i]);
18 // defines stop locations for each Person
19 for (int j = 0; j < stops[i].length; j++) {
20 person.addStop(stops[i][j][0], stops[i][j][1]);
21 }
22 manager.addEntity(person);
23 }
24 }
25
清单 10 用清单 9 中的 initLoop 代码定义了一组 Person 实体。这段代码使用几个平行的数组(根据 locs 数组的长度)提供有关要创建的对象的信息。 locs 数组为用控制路径提供的一组定义好的开始位置提供了索引。 starts 值指定在什么时间让 Person 开始移动。 appear 值定义了什么时候应当让 Person 变为可见(通常是在开始移动之前)。 stops 值指定每个 Person 可以有的停止点(可能有多个)。
尽管下面显示的代码是以手工键入值,但是也可以通过增加表示位置实体状态的新颜色来从控制路径获得大多数这些输入值。这种增强可以简化这些值的输入,并减少控制路径改变时出错的可能性。
清单 10. 创建所有 Person 实体
2 static public void createPeople(BuildingManager manager,
3 ImageIcon employIcon,
4 ImageIcon fireIcon,
5 ImageIcon disabledIcon)
6 {
7 // Main character - ALEX
8 int locs[] = new int[] {42};
9 String names[] = new String[] {"Alex"};
10 int starts[] = new int[] { 300 };
11 int appear[] = new int[] { 0 };
12 int stops[][][] = new int[][][] {{}};
13 initLoop(manager, employIcon, locs, names, starts, appear, stops);
14 // Some disabled people
15 locs = new int[] { 39, 45 };
16 names = new String[] { "Karen", "Mike" };
17 starts = new int[] { 0, 0};
18 appear = new int[] { 0, 0 };
19 stops = new int[][][] {{{1, 164}, {560, 20}},
20 {{1, 141}, {460, 30}}};
21 initLoop(manager, disabledIcon, locs, names, starts, appear, stops);
22 // Some Assisters
23 locs = new int[] {44, 49, 37, 46};
24 names = new String[] { "Tom", "Joe", "Cathy", "Larry" };
25 starts = new int[] { 0, 0, 0, 0};
26 appear = new int[] { 0, 0, 0, 0 };
27 stops = new int [][][] {{{120, 52}, {560, 20}},
28 {{155, 24}, {560, 20}},
29 {{122, 27}, {460, 30}},
30 {{100, 59}, {460, 30}}};
31 initLoop(manager, employIcon, locs, names, starts, appear, stops);
32 // A firemen
33 locs = new int[] { 25 };
34 names = new String[] { "FD", "FD 2", "FD 3" };
35 starts = new int[] { 400, 400, 400};
36 appear = new int[] { 400, 400, 400 };
37 stops = new int [][][] {{},{}, {}};
38 initLoop(manager, fireIcon, locs, names, starts, appear, stops);
39 :
40 : **** many additional definition sets omitted ****
41 :
42 }
43
最后,清单 11 显示了如何创建一个 Alarm 实体。显然,我们可以容易地根据需要增加更多的报警。
清单 11. 创建报警
2 static public void createAlarms(BuildingManager manager) {
3 final int alarms[] = { 12 };
4 LinkedList startPts = (LinkedList)manager.
5 getAvailableStartingPoints();
6 for (int i = 0; i < alarms.length; i++) {
7 manager.addEntity(
8 new Alarm(manager, (Point2D)startPts.get(alarms[i])));
9 }
10 }
11
结束语
在本文中,我们展示了如何用无损图像、Swing 技术和自定义的动画引擎来生成 2D 动画中的运动路径。这种方法使我们可以用控制路径以一种快速和可预言的方式可视化地创建动画。这种技术的优点如下:
易于使用
大多数编辑程序都有几种生成直线、圆弧和其他形状的方法。这些选择使我们可以手工迅速生成一些路径,同时减少错误。这对于某些行为是非常有用的。
引用图像
当动画相对于背景图像运动时(如在 图 1中),我们可能希望让对象在图像中的某些区域内运动,如让对象保持在走廊中。许多图像编辑程序可以让我们使用半透明的图层在背景图上面生成控制图像。这样我们就可以容易地创建与背景图相匹配的控制路径,因为在生成控制图像时可以同时看到叠加在一起的两幅图像。
增加绘制
通过混合颜色,我们可以在一个位置上编码多种行为。例如,使用 RGB 颜色时,可以用红色 (0xFF0000) 表示一个对象所要走的路径,绿色 (0x00FF00) 编码另一个对象要走的路径。使用增加绘制时,在路径交叉的点将会是黄色 (0xFFFF00)。
使用这种增加绘制模型时,当使用比如说一个 32 位颜色模型时,可以从一个给定位置以位掩码的形式提取出 32 种不同的行为。尽管我们只描述了一种简单的行为,但是可以编码的行为的数量只受在该图像格式中每种颜色所具有的位数的限制。
我们还给出并描述了沿着一组路径移动对象的一个简单动画引擎。在例子中,每一个对象称为 Entity 并实现为 JLabel ,它们被驱动周期性地更新其位置和/或者外观。使用一个长期运行的计时器线程驱动这个过程。用一个 JPanel 作为所有对象的容器,并作为绘制背景的方法。