A lot of people were stirred when the Web first brought full-color images to the Internet. These sections, color images are simply to be expected, while a growing interest is being placed on animation, or moving images. If a picture can tell a thousand words, imagine what a bunch of pictures shown very rapidly can tell!
Today's lesson focuses on how the effect of animated movement is conveyed in Java using a series of images displayed rapidly. This technique is really nothing new to computers or programming, although it is pretty new to the Web. If you're thinking this description of today's lesson sounds awfully familiar, it's because you've already learned about animation in earlier lessons. The difference is that today's lesson is going to take you much further in learning about what animation is and how to do some really powerful things with it.
More specifically, today you'll learn about the following:
Although part of today's lesson is theoretical, you'll finish up the lesson by creating a powerful set of reusable sprite animation classes. Don't worry if you don't know what a sprite is yet-you will soon enough!
Before getting into animation as it relates to Java, it's important to understand the basics of what animation is and how it works. So let's begin by asking the fundamental question: What is animation? Put simply, animation is the illusion of movement. Am I telling you that every animation you've ever seen is really just an illusion? That's exactly right! And probably the most surprising animated illusion is one that captured our attention long before modern computers-the television. When you watch television, you see lots of things moving around, but what you perceive as movement is really just a trick being played on your eyes.
New Term |
|
New Term |
|
When programming animation in Java, you typically have the ability to manipulate the frame rate a decent amount. The most obvious limitation on frame rate is the speed at which the computer can generate and display the animation frames. In Java, this is a crucial point because Java applets aren't typically known to be speed demons. However, the recent release of just-in-time Java compilers has helped speed up Java applets, along with alleviating some of the performance concerns associated with animation.
Note |
|
I know you're probably itching to see some real animation in Java, but there are a few more issues to cover before getting into the details of animation programming. More specifically, it's important for you to understand the primary types of animation used in Java programming. There are actually a lot of different types of animation, all of which are useful in different instances. However, for the purposes of implementing animation in Java, I've broken animation down into two basic types: frame-based animation and cast-based animation.
The most simple type of animation is frame-based animation, which is the primary type of animation found on the Web. Frame-based animation involves simulating movement by displaying a sequence of pregenerated, static frame images. A movie is a perfect example of frame-based animation; each frame of the film is a frame of animation, and when the frames are shown in rapid succession, they create the illusion of movement.
New Term |
|
Note |
|
A more powerful animation technique often employed in games and educational software is cast-based animation, which is also known as sprite animation. Cast-based animation involves graphical objects that move independently of a background. At this point, you may be a little confused by my usage of the term "graphical object" when referring to parts of an animation. In this case, a graphical object is something that logically can be thought of as a separate entity from the background of an animation image. For example, in an animation of the solar system, the planets would be separate graphical objects that are logically independent of the starry background.
New Term |
|
New Term |
|
Note |
|
I mentioned in the frame-based animation discussion that television is a good example of frame-based animation. But can you think of something on television that is created in a manner similar to cast-based animation (other than animated movies and cartoons)? Have you ever wondered how weatherpeople magically appear in front of a computer-generated map showing the weather? The news station uses a technique known as blue-screening, which enables them to overlay the weatherperson on top of the weather map in real time. It works like this: The person stands in front of a blue backdrop, which serves as a transparent background. The image of the weatherperson is overlaid onto the weather map; the trick is that the blue background is filtered out when the image is overlaid so that it is effectively transparent. In this way, the weatherperson is acting exactly like a sprite!
The weatherperson example brings up a very important point regarding sprites: transparency. Because bitmapped images are rectangular by nature, a problem arises when sprite images aren't rectangular in shape. In sprites that aren't rectangular in shape, which is the majority of sprites, the pixels surrounding the sprite image are unused. In a graphics system without transparency, these unused pixels are drawn just like any others. The end result is sprites that have visible rectangular borders around them, which completely destroys the effectiveness of having sprites overlaid on a background image.
What's the solution? Well, one solution is to make all your sprites rectangular. Unless you're planning to write an applet showing dancing boxes, a more realistic solution is transparency, which allows you to define a certain color in an image as unused, or transparent. When pixels of this color are encountered by graphics drawing routines, they are simply skipped, leaving the original background intact. Transparent colors in images act exactly like the weatherperson's blue screen.
New Term |
|
In many instances, you will want some sprites to appear on top of others. For example, in the solar system animation you would want to be able to see some planets passing in front of others. You handle this problem by assigning each planet sprite a screen depth, which is also referred to as Z-order.
New Term |
|
Just to make sure that you get a clear picture of how Z-order works, let's go back for a moment to the good old sections of traditional animation. Traditional animators, such as those at Disney, used celluloid sheets to draw animated objects. They drew on these because they could be overlaid on a background image and moved independently. This was known as cel animation and should sound vaguely familiar. (Cel animation is an early version of sprite animation.) Each cel sheet corresponds to a unique Z-order value, determined by where in the pile of sheets the sheet is located. If an image near the top of the pile happens to be in the same location on the cel sheet as any lower images, it conceals them. The location of each image in the stack of cel sheets is its Z-order, which determines its visibility precedence. The same thing applies to sprites in cast-based animations, except that the Z-order is determined by the order in which the sprites are drawn, rather than the cel sheet location. This concept of a pile of cel sheets representing all the sprites in a sprite system will be useful later today when you develop the sprite classes.
Although collision detection is primarily useful only in games, it is an important component of sprite animation. Collision detection is the process of determining whether sprites have collided with each other. Although collision detection doesn't directly play a role in creating the illusion of movement, it is tightly linked to sprite animation and extremely useful in some scenarios, such as games.
New Term |
|
Not surprisingly, there are many approaches to handling collision detection. The simplest approach is to compare the bounding rectangles of each sprite with the bounding rectangles of all the other sprites. This method is efficient, but if you have objects that are not rectangular, a certain degree of error occurs when the objects brush by each other. This is because the corners might overlap and indicate a collision when really only the transparent areas are overlapping. The more irregular the shape of the sprites, the more errors typically occur. Figure 24.1 shows how simple rectangle collision works.
Figure 24.1 : Collision detection using simple rectangle collision.
In Figure 24.1 the areas determining the collision detection are shaded. You can see how simple rectangle collision detection isn't very accurate unless you're dealing with sprites that are rectangular in shape. An improvement on this technique is to shrink the collision rectangles a little, which reduces the corner error. This method improves things a little, but has the potential of causing error in the reverse direction by allowing sprites to overlap in some cases without signaling a collision. Not surprisingly, shrunken rectangle collision works best when you are dealing with sprites that are roughly circular in shape.
Figure 24.2 shows how shrinking the collision rectangles can improve the error on simple rectangle collision detection. Shrunken rectangle collision is just as efficient as simple rectangle collision because all you are doing is comparing rectangles for intersection.
Figure 24.2 : Collision detection using shrunken rectangle collision.
The most accurate collision detection technique is to detect collision based on the sprite image data, which involves actually checking whether transparent parts of the sprite or the sprite images themselves are overlapping. In this case, you would get a collision only if the actual sprite images are overlapping. This is the ideal technique for detecting collisions because it is exact and allows objects of any shape to move by each other without error. Figure 24.3 shows collision detection using the sprite image data.
Figure 24.3 : Collision detection using sprite image data.
Unfortunately, this technique requires far more overhead than the other types of collision detection and is often a major bottleneck in performance. Furthermore, implementing image data collision detection can get very messy. Considering these facts, you'll focus your efforts later today on implementing the first two types of collision detection.
There is one last topic to cover before getting into the details of animation programming in Java: tracking images. Since animations typically require multiple images, the issue of managing images as they are being transferred over a Web connection can't be overlooked. The primary issue with images being transferred is the limited bandwidth many of us have in regard to our Web connections. Since many of us have a limited bandwidth connection (pronounced modem), the speed at which images are transferred over such a Web connection often causes a noticeable delay in a Java applet reliant on them, such as any applet displaying animations.
There is a standard technique for dealing with transfer delay as it affects static images. You've no doubt seen this technique at work in your Web browser when you've viewed images in Web pages. The technique is known as interlacing and makes images appear blurry until they have been completely transferred. To use interlacing, images must be stored in an interlaced format (usually GIF version 89a), which means that the image data is arranged such that the image can be displayed before it is completely transmitted. Interlacing is a good approach to dealing with transmission delays for static images because it enables you to see the image as it is being transferred. Without interlacing, you have to wait until the entire image has been transferred before you can see it at all.
Before you get too excited about interlacing, let me point out that it is useful only for static images. You're probably wondering why this is the case. It has to do with the fact that animations (dynamic images) rely on rapidly displaying a sequence of images over time, all of which must be readily available to successfully create the effect of movement. An animation sequence simply wouldn't look right using interlacing because some of the images would be transferred before others.
A good solution to the transfer-delay problem in animated images would be to just wait until all the images have been transferred before displaying the animation. That's fine, but it requires you to know the status of images as they are being transferred. How can you possibly know this? Enter the Java media tracker.
The Java media tracker is an object that tracks when media objects, such as images, have been successfully transferred. Using the media tracker, you can keep track of any number of media objects and query to see when they have finished being transmitted. For example, suppose you have an animation with four images. You would register each of these images with the media tracker and then wait until they have all been transferred before displaying the animation. The media tracker keeps up with the load status of each image. When the media tracker reports that all the images have been successfully loaded, you are guaranteed that your animation has all the necessary images to display correctly.
The Java MediaTracker class is part of the awt package and contains a variety of members and methods for tracking media objects. Unfortunately, the MediaTracker class that ships with release 1.02 of the Java Developer's Kit supports only image tracking. Future versions of Java are expected to add support for other types of media objects such as sound and music.
The MediaTracker class provides member flags for representing various states associated with tracked media objects. These flags are returned by many of the member functions of MediaTracker, and are the following:
The MediaTracker class provides a variety of methods for helping to track media objects:
MediaTracker(Component comp)-The constructor for MediaTracker takes a single parameter of type Component. This parameter specifies the Component object on which tracked images will eventually be drawn. This parameter reflects the current limitation of being able to track only images with the MediaTracker class, and not sounds or other types of media.
void addImage(Image image, int id)-The addImage method adds an image to the list of images currently being tracked. This method takes as its first parameter an Image object and as its second parameter an identifier that uniquely identifies the image. If you want to track a group of images together, you can use the same identifier for each image.
synchronized void addImage(Image image, int id, int w, int h)-This addImage method is similar to the first one, but it has additional parameters for specifying the width and height of a tracked image. This version of addImage is used for tracking images that you are going to scale; you pass the width and height to which you are scaling the image.
boolean checkID(int id)-After you have added images to the MediaTracker object, you are ready to check their status. You use the checkID method to check whether images matching the passed identifier have finished loading. The checkID method returns false if the images have not finished loading, and true otherwise. This method returns true even if the loading has been aborted or if an error has occurred. You must call the appropriate error-checking methods to see if an error has occurred. (You'll learn about the error-checking methods a little later in this section.) The checkID method does not load an image if that image has not already begun loading.
synchronized boolean checkID(int id, boolean load)-This checkID method is similar to the first one except that it enables you to specify that the image should be loaded even if it hasn't already begun loading, which is carried out by passing true in the load parameter.
boolean checkAll()-The checkAll method is similar to the checkID methods, except that it applies to all images, not just those matching a certain identifier. The checkAll method checks to see if the images have finished loading, but doesn't load any images that haven't already begun loading.
synchronized boolean checkAll(boolean load)-This checkAll method also checks the status of loading images, but enables you to indicate that images are to be loaded if they haven't started already.
void waitForID(int id)-You use the waitForID method to begin loading images with a certain identifier. This identifier should match the identifier used when the images were added to the media tracker with the addImage method. The waitForID method is synchronous, meaning that it does not return until all the specified images have finished loading or an error occurs.
synchronized boolean waitForID(int id, long ms)-This waitForID method is similar to the first one except that it enables you to specify a timeout period, in which case the load will end and waitForID will return true. You specify the timeout period in milliseconds by using the ms parameter.
void waitForAll()-The waitForAll method is similar to the waitForID methods, except that it operates on all images.
synchronized boolean waitForAll(long ms)-This waitForAll method is similar to the first one except that it enables you to specify a timeout period, in which case the load will end and waitForAll will return true. You specify the timeout period in milliseconds by using the ms parameter.
int statusID(int id, boolean load)-You use the statusID method to determine the status of images matching the identifier passed in the id parameter. statusID returns the bitwise OR of the status flags related to the images. The possible flags are LOADING, ABORTED, ERRORED, and COMPLETE. The second parameter to statusID-load-should be familiar to you by now because of its use in the other media- tracker methods. It specifies whether you want the images to begin loading if they haven't begun already. This functionality is similar to that provided by the second versions of the checkID and waitForID methods.
int statusAll(boolean load)-The statusAll method is similar to the statusID method; the only difference is that statusAll returns the status of all the images being tracked rather than just those matching a specific identifier.
synchronized boolean isErrorID(int id)-The isErrorID method checks the error status of images being tracked, based on the id identifier argument. This method basically checks the status of each image for the ERRORED flag. Note that this method will return true if any of the images have errors; it's up to you to determine which specific images had errors.
synchronized boolean isErrorAny()-The isErrorAny method is similar to the isErrorID method, except that it checks on all images rather than just those matching a certain identifier. Like isErrorID, isErrorAny will return true if any of the images have errors; it's up to you to determine which specific images had errors.
synchronized Object[] getErrorsID(int id)-If you use isErrorID or isErrorAny and find out that there are load errors, you need to figure out which images have errors. You do this by using the getErrorsID method. This method returns an array of Objects containing the media objects that have load errors. In the current implementation of the MediaTracker class, this array is always filled with Image objects. If there are no errors, this method returns null.
synchronized Object[] getErrorsAny()-The getErrorsAny method is very similar to getErrorsID, except that it returns all errored images.
That wraps up the description of the MediaTracker class. Now that you understand what the class is all about, you're probably ready to see it in action. Don't worry-the Sharks sample applet you'll develop later today will put the media tracker through its paces.
As you learned earlier in today's lesson, sprite animation involves the movement of individual graphic objects called sprites. Unlike simple frame animation, sprite animation involves a decent amount of overhead. More specifically, it is necessary to develop not only a sprite class, but also a sprite management class for keeping up with all the sprites you've created. This is necessary because sprites need to be able to interact with each other through a common mechanism. Besides, it is nice to be able to work with the sprites as a whole when it comes to things like actually drawing the sprites on the screen.
In this section, you'll learn how to implement sprite animation in Java by creating a suite of sprite classes. The primary sprite classes are Sprite and SpriteVector. However, there are also a few support classes that you will learn about as you get into the details of these two primary classes. The Sprite class models a single sprite and contains all the information and methods necessary to get a single sprite up and running. However, the real power of sprite animation is harnessed by combining the Sprite class with the SpriteVector class, which is a container class that manages multiple sprites and their interaction with each other.
Although sprites can be implemented simply as movable graphical objects, I mentioned earlier that the sprite class developed today will also contain support for frame animation. A frame-animated sprite is basically a sprite with multiple frame images that can be displayed in succession. Your Sprite class will support frame animation in the form of an array of frame images and some methods for setting the frame image currently being displayed. Using this approach, you'll end up with a Sprite class that supports both fundamental types of animation, which gives you more freedom in creating animated Java applets.
Before jumping into the details of how the Sprite class is implemented, take a moment to think about the different pieces of information that a sprite must keep up with. When you understand the components of a sprite at a conceptual level, it will be much easier to understand the Java code. So exactly what information should the Sprite class maintain? The following list contains the key information that the Sprite class needs to include:
The first component, an array of frame images, is necessary to carry out the frame animations. Even though this sounds like you are forcing a sprite to have multiple animation frames, a sprite can also use a single image. In this way, the frame animation aspects of the sprite are optional. The current frame keeps up with the current frame of animation. In a typical frame-animated sprite, the current frame is incremented to the next frame when the sprite is updated.
The XY position stores the position of the sprite. You move the sprite simply by altering this position. Alternatively, you can set the velocity and let the sprite alter its position automatically based on the velocity.
The Z-order represents the depth of the sprite in relation to other sprites. Ultimately, the Z-order of a sprite determines its drawing order (you'll learn more on that a little later).
Finally, the boundary of a sprite refers to the bounded region in which the sprite can move. All sprites are bound by some region-usually the size of the applet window. The sprite boundary is important because it determines the limits of a sprite's movement.
Now that you understand the core information required by the Sprite class, it's time to get into the specific Java implementation. Let's begin with the Sprite class's member variables, which follow:
public static final int BA_STOP = 0, BA_WRAP = 1, BA_BOUncE = 2, BA_DIE = 3; protected Component component; protected Image[] image; protected int frame, frameInc, frameDelay, frameTrigger; protected Rectangle position, collision; protected int zOrder; protected Point velocity; protected Rectangle bounds; protected int boundsAction; protected boolean hidden = false;
The member variables include the important sprite information mentioned earlier, along with some other useful information. Most notably, you are probably curious about the static final members at the beginning of the listing. These members are constant identifiers that define bounds actions for the sprite. Bounds actions are actions that a sprite takes in response to reaching a boundary, such as wrapping to the other side or bouncing. Bounds actions are mutually exclusive, meaning that only one can be set at a time.
The Component member variable is necessary because an ImageObserver object is required to retrieve information about an image. But what does Component have to do with ImageObserver? The Component class implements the ImageObserver interface, and the Applet class is derived from Component. So a Sprite object gets its image information from the Java applet itself, which is used to initialize the Component member variable.
Note |
|
The frameInc member variable is used to provide a means to change the way that the animation frames are updated. For example, in some cases you might want the frames to be displayed in the reverse order. You can easily do this by setting frameInc to -1 (its typical value is 1). The frameDelay and frameTrigger member variables are used to provide a means of varying the speed of the frame animation. You'll see how the speed of animation is controlled when you learn about the incFrame method later today.
The position member variable is a Rectangle object representing the current position of the sprite. The collision member variable is also a Rectangle object and is used to support rectangle collision detection. You'll see how collision is used later in today's lesson when you learn about the setCollision and testCollision methods.
The zOrder and velocity member variables simply store the Z-order and velocity of the sprite. The bounds member variable represents the boundary rectangle to which the sprite is bounded, while the boundsAction member variable is the bounds action that is taken when the sprite encounters the boundary.
The last member variable, hidden, is a boolean flag that determines whether the sprite is hidden. By setting this variable to false, the sprite is hidden from view. Its default setting is true, meaning that the sprite is visible.
The Sprite class has two constructors. The first constructor creates a Sprite without support for frame animation, meaning that it uses a single image to represent the sprite. The code for this constructor follows:
public Sprite(Component comp, Image img, Point pos, Point vel, int z, int ba) { component = comp; image = new Image[1]; image[0] = img; setPosition(new Rectangle(pos.x, pos.y, img.getWidth(comp), img.getHeight(comp))); setVelocity(vel); frame = 0; frameInc = 0; frameDelay = frameTrigger = 0; zOrder = z; bounds = new Rectangle(0, 0, comp.size().width, comp.size().height); boundsAction = ba; }
This constructor takes an image, a position, a velocity, a Z-order, and a boundary action as parameters. The second constructor takes an array of images and some additional information about the frame animations. The code for the second constructor follows:
public Sprite(Component comp, Image[] img, int f, int fi, int fd, Point pos, Point vel, int z, int ba) { component = comp; image = img; setPosition(new Rectangle(pos.x, pos.y, img[f].getWidth(comp), img[f].getHeight(comp))); setVelocity(vel); frame = f; frameInc = fi; frameDelay = frameTrigger = fd; zOrder = z; bounds = new Rectangle(0, 0, comp.size().width, comp.size().height); boundsAction = ba; }
The additional information required of this constructor includes the current frame, frame increment, and frame delay.
Warning |
|
public Point getVelocity() { return velocity; } public void setVelocity(Point vel) { velocity = vel; }
There are more access methods for getting and setting other member variables in Sprite, but they are just as straightforward as getVelocity and setVelocity. Rather than waste time on those, let's move on to some more interesting methods!
The incFrame method is the first Sprite method with any real substance:
protected void incFrame() { if ((frameDelay > 0) && (--frameTrigger <= 0)) { // Reset the frame trigger frameTrigger = frameDelay; // Increment the frame frame += frameInc; if (frame >= image.length) frame = 0; else if (frame < 0) frame = image.length - 1; } }
incFrame is used to increment the current animation frame. It first checks the frameDelay and frameTrigger member variables to see whether the frame should actually be incremented. This check is what allows you to vary the frame animation speed for a sprite, which is done by changing the value of frameDelay. Larger values for frameDelay result in a slower animation speed. The current frame is incremented by adding frameInc to frame. frame is then checked to make sure that its value is within the bounds of the image array, because it is used later to index into the array when the frame image is drawn.
The setPosition methods set the position of the sprite. Their source code follows:
void setPosition(Rectangle pos) { position = pos; setCollision(); } public void setPosition(Point pos) { position.move(pos.x, pos.y); setCollision(); }
Even though the sprite position is stored as a rectangle, the setPosition methods allow you to specify the sprite position as either a rectangle or a point. In the latter version, the position rectangle is simply moved to the specified point. After the position rectangle is moved, the collision rectangle is set with a call to setCollision. setCollision is the method that sets the collision rectangle for the sprite. The source code for setCollision follows:
protected void setCollision() { collision = position; }
Notice that setCollision sets the collision rectangle equal to the position rectangle, which results in simple rectangle collision detection. Because there is no way to know what sprites will be shaped like, you leave it up to derived sprite classes to implement versions of setCollision with specific shrunken rectangle calculations. So to implement shrunken rectangle collision, you just calculate a smaller collision rectangle in setCollision.
This isPointInside method is used to test whether a point lies inside the sprite. The source code for isPointInside follows:
boolean isPointInside(Point pt) { return position.inside(pt.x, pt.y); }
This method is handy for determining whether the user has clicked on a certain sprite. This is useful in applets where you want to be able to click on objects and move them around, such as a chess game. In a chess game, each piece would be a sprite, and you would use isPointInside to find out which piece the user clicked.
The method that does most of the work in Sprite is the update method, which is shown in Listing 24.1.
Listing 24.1. The Sprite class's update method.
1: public boolean update() { 2: // Increment the frame 3: incFrame(); 4: 5: // Update the position 6: Point pos = new Point(position.x, position.y); 7: pos.translate(velocity.x, velocity.y); 8: 9: // Check the bounds 10: // Wrap? 11: if (boundsAction == Sprite.BA_WRAP) { 12: if ((pos.x + position.width) < bounds.x) 13: pos.x = bounds.x + bounds.width; 14: else if (pos.x > (bounds.x + bounds.width)) 15: pos.x = bounds.x - position.width; 16: if ((pos.y + position.height) < bounds.y) 17: pos.y = bounds.y + bounds.height; 18: else if (pos.y > (bounds.y + bounds.height)) 19: pos.y = bounds.y - position.height; 20: } 21: // Bounce? 22: else if (boundsAction == Sprite.BA_BOUncE) { 23: boolean bounce = false; 24: Point vel = new Point(velocity.x, velocity.y); 25: if (pos.x < bounds.x) { 26: bounce = true; 27: pos.x = bounds.x; 28: vel.x = -vel.x; 29: } 30: else if ((pos.x + position.width) > 31: (bounds.x + bounds.width)) { 32: bounce = true; 33: pos.x = bounds.x + bounds.width - position.width; 34: vel.x = -vel.x; 35: } 36: if (pos.y < bounds.y) { 37: bounce = true; 38: pos.y = bounds.y; 39: vel.y = -vel.y; 40: } 41: else if ((pos.y + position.height) > 42: (bounds.y + bounds.height)) { 43: bounce = true; 44: pos.y = bounds.y + bounds.height - position.height; 45: vel.y = -vel.y; 46: } 47: if (bounce) 48: setVelocity(vel); 49: } 50: // Die? 51: else if (boundsAction == Sprite.BA_DIE) { 52: if ((pos.x + position.width) < bounds.x || pos.x > bounds.width || 53: (pos.y + position.height) < bounds.y || pos.y > bounds.height) { 54: return true; 55: } 56: } 57: // Stop (default) 58: else { 59: if (pos.x < bounds.x || 60: pos.x > (bounds.x + bounds.width - position.width)) { 61: pos.x = Math.max(bounds.x, Math.min(pos.x, 62: bounds.x + bounds.width - position.width)); 63: setVelocity(new Point(0, 0)); 64: } 65: if (pos.y < bounds.y || 66: pos.y > (bounds.y + bounds.height - position.height)) { 67: pos.y = Math.max(bounds.y, Math.min(pos.y, 68: bounds.y + bounds.height - position.height)); 69: setVelocity(new Point(0, 0)); 70: } 71: } 72: setPosition(pos); 73: 74: return false; 75: }
Analysis |
|
Notice that update finishes by returning a boolean value. This boolean value specifies whether the sprite should be killed, which provides a means for sprites to be destroyed when the BA_DIE bounds action is defined. If this seems a little strange, keep in mind that the only way to get rid of a sprite is to remove it from the sprite vector. I know, you haven't learned much about the sprite vector yet, but trust me on this one. Since individual sprites know nothing about the sprite vector, they can't directly tell it what to do. So the return value of the update method is used to communicate to the sprite vector whether a sprite needs to be killed. A return of true means that the sprite is to be killed, and false means let it be.
Note |
|
Another important method in the Sprite class is draw, whose source code follows:
public void draw(Graphics g) { // Draw the current frame if (!hidden) g.drawImage(image[frame], position.x, position.y, component); }
After wading through the update method, the draw method looks like a piece of cake! It simply uses the drawImage method to draw the current sprite frame image to the Graphics object that is passed in. Notice that the drawImage method requires the image, XY position, and component (ImageObserver) to carry this out.
The last method in Sprite is testCollision, which is used to check for collisions between sprites:
protected boolean testCollision(Sprite test) { // Check for collision with another sprite if (test != this) return collision.intersects(test.getCollision()); return false; }
The sprite to test for collision is passed in the test parameter. The test simply involves checking whether the collision rectangles intersect. If so, testCollision returns true. testCollision isn't all that useful within the context of a single sprite, but it is very handy when you put together the SpriteVector class, which you are going to do next.
At this point, you have a Sprite class with some pretty impressive features, but you don't really have any way to manage it. Of course, you could go ahead and create an applet with some Sprite objects, but how would they be able to interact with each other? The answer to this question is the SpriteVector class, which handles all the details of maintaining a list of sprites and handling the interactions between them.
The SpriteVector class is derived from the Vector class, which is a standard class provided in the java.util package. The Vector class models a growable array of objects. In this case, the SpriteVector class is used as a container for a growable array of Sprite objects.
The SpriteVector class has only one member variable, background, which is a Background object:
protected Background background;
This Background object represents the background on which the sprites appear. It is initialized in the constructor for SpriteVector, like this:
public SpriteVector(Background back) { super(50, 10); background = back; }
The constructor for SpriteVector simply takes a Background object as its only parameter. You'll learn about the Background class a little later today. Notice that the constructor for SpriteVector calls the Vector parent class constructor and sets the default storage capacity (50) and amount to increment the storage capacity (10) if the vector needs to grow.
SpriteVector contains two access methods for getting and setting the background member variable, which follow:
public Background getBackground() { return background; } public void setBackground(Background back) { background = back; }
These methods are useful whenever you have an animation that needs to have a changing background. To change the background, you simply call setBackground and pass in the new Background object.
The getEmptyPosition method is used by the SpriteVector class to help position new sprites. Listing 24.2 contains the source code for getEmptyPosition.
Listing 24.2. The SpriteVector class's getEmptyPosition method.
1: public Point getEmptyPosition(Dimension sSize) { 2: Rectangle pos = new Rectangle(0, 0, sSize.width, sSize.height); 3: Random rand = new Random(System.currentTimeMillis()); 4: boolean empty = false; 5: int numTries = 0; 6: 7: // Look for an empty position 8: while (!empty && numTries++ < 50) { 9: // Get a random position 10: pos.x = Math.abs(rand.nextInt() % 11: background.getSize().width); 12: pos.y = Math.abs(rand.nextInt() % 13: background.getSize().height); 14: 15: // Iterate through sprites, checking if position is empty 16: boolean collision = false; 17: for (int i = 0; i < size(); i++) { 18: Rectangle testPos = ((Sprite)elementAt(i)).getPosition(); 19: if (pos.intersects(testPos)) { 20: collision = true; 21: break; 22: } 23: } 24: empty = !collision; 25: } 26: return new Point(pos.x, pos.y); 27: }
Analysis |
|
Sprite isPointInside(Point pt) { // Iterate backward through the sprites, testing each for (int i = (size() - 1); i >= 0; i--) { Sprite s = (Sprite)elementAt(i); if (s.isPointInside(pt)) return s; } return null; }
If the point passed in the parameter pt lies in a sprite, isPointInside returns the sprite. Notice that the sprite vector is searched in reverse, meaning that the last sprite is checked before the first. The sprites are searched in this order for a very important reason: Z-order. The sprites are stored in the sprite vector sorted in ascending Z-order, which specifies their depth on the screen. Therefore, sprites near the beginning of the list are sometimes concealed by sprites near the end of the list. If you want to check for a point lying within a sprite, it makes sense to check the topmost sprites first-that is, the sprites with larger Z-order values. If this sounds a little confusing, don't worry; you'll learn more about Z-order later today when you get to the add method.
As in Sprite, the update method is the key method in SpriteVector because it handles updating all the sprites. Listing 24.3 contains the source code for update.
Listing 24.3. The SpriteVector class's update method.
1: public void update() { 2: // Iterate through sprites, updating each 3: Sprite s, sHit; 4: Rectangle lastPos; 5: for (int i = 0; i < size(); ) { 6: // Update the sprite 7: s = (Sprite)elementAt(i); 8: lastPos = new Rectangle(s.getPosition().x, s.getPosition().y, 9: s.getPosition().width, s.getPosition().height); 10: boolean kill = s.update(); 11: 12: // Should the sprite die? 13: if (kill) { 14: removeElementAt(i); 15: continue; 16: } 17: 18: // Test for collision 19: int iHit = testCollision(s); 20: if (iHit >= 0) 21: if (collision(i, iHit)) 22: s.setPosition(lastPos); 23: i++; 24: } 25: }
Analysis |
|
protected boolean collision(int i, int iHit) { // Do nothing return false; }
The collision method is responsible for handling any actions that result from a collision between sprites. The action in this case is to simply do nothing, which allows sprites to pass over each other with nothing happening. This method is where you provide specific collision actions in derived sprites. For example, in a weather-simulator animation, you might want clouds to cause lightning when they collide.
The testCollision method is used to test for collisions between a sprite and the rest of the sprites in the sprite vector:
protected int testCollision(Sprite test) { // Check for collision with other sprites Sprite s; for (int i = 0; i < size(); i++) { s = (Sprite)elementAt(i); if (s == test) // don't check itself continue; if (test.testCollision(s)) return i; } return -1; }
The sprite to be tested is passed in the test parameter. The sprites are then iterated through, and the testCollision method in Sprite is called for each. Notice that testCollision isn't called on the test sprite if the iteration refers to the same sprite. To understand the significance of this code, consider the effect of passing testCollision the same sprite the method is being called on; you would be checking to see if a sprite was colliding with itself, which would always return true. If a collision is detected, the Sprite object that has been hit is returned from testCollision.
The draw method handles drawing the background, as well as drawing all the sprites:
public void draw(Graphics g) { // Draw the background background.draw(g); // Iterate through sprites, drawing each for (int i = 0; i < size(); i++) ((Sprite)elementAt(i)).draw(g); }
The background is drawn with a simple call to the draw method of the Background object. The sprites are then drawn by iterating through the sprite vector and calling the draw method for each.
The add method is probably the trickiest method in the SpriteVector class. Listing 24.4 contains the source code for add.
Listing 24.4. The SpriteVector class's add method.
1: public int add(Sprite s) { 2: // Use a binary search to find the right location to insert the 3: // new sprite (based on z-order) 4: int l = 0, r = size(), i = 0; 5: int z = s.getZOrder(), 6: zTest = z + 1; 7: while (r > l) { 8: i = (l + r) / 2; 9: zTest = ((Sprite)elementAt(i)).getZOrder(); 10: if (z < zTest) 11: r = i; 12: else 13: l = i + 1; 14: if (z == zTest) 15: break; 16: } 17: if (z >= zTest) 18: i++; 19: 20: insertElementAt(s, i); 21: return i; 22: }
Analysis |
|
Actually, there is some unfinished business to deal with before you try out the sprite classes. I'm referring to the Background class used in SpriteVector. While you're at it, let's go ahead and look at a few different background classes that you might find handy.
If you recall, I mentioned earlier today that the Background class provides the overhead of managing a background for the sprites to appear on top of. The source code for the Background class is shown in Listing 24.5.
Listing 24.5. The Background class.
1: public class Background { 2: protected Component component; 3: protected Dimension size; 4: 5: public Background(Component comp) { 6: component = comp; 7: size = comp.size(); 8: } 9: 10: public Dimension getSize() { 11: return size; 12: } 13: 14: public void draw(Graphics g) { 15: // Fill with component color 16: g.setColor(component.getBackground()); 17: g.fillRect(0, 0, size.width, size.height); 18: g.setColor(Color.black); 19: } 20: }
Analysis |
|
You're probably thinking that this Background object isn't too exciting. Couldn't you just stick this drawing code directly into SpriteVector's draw method? Yes, you could, but then you would miss out on the benefits provided by the more derived background classes, ColorBackground and ImageBackground, which are explained next. The background classes are a good example of how object-oriented design makes Java code much cleaner and easier to extend.
The ColorBackground class provides a background that can be filled with any color. Listing 24.6 contains the source code for the ColorBackground class.
Listing 24.6. The ColorBackground class.
1: public class ColorBackground extends Background { 2: protected Color color; 3: 4: public ColorBackground(Component comp, Color c) { 5: super(comp); 6: color = c; 7: } 8: 9: public Color getColor() { 10: return color; 11: } 12: 13: public void setColor(Color c) { 14: color = c; 15: } 16: 17: public void draw(Graphics g) { 18: // Fill with color 19: g.setColor(color); 20: g.fillRect(0, 0, size.width, size.height); 21: g.setColor(Color.black); 22: } 23: }
Analysis |
|
A more interesting Background derived class is ImageBackground, which uses an image as the background. Listing 24.7 contains the source code for the ImageBackground class.
Listing 24.7. The ImageBackground class.
1: public class ImageBackground extends Background { 2: protected Image image; 3: 4: public ImageBackground(Component comp, Image img) { 5: super(comp); 6: image = img; 7: } 8: 9: public Image getImage() { 10: return image; 11: } 12: 13: public void setImage(Image img) { 14: image = img; 15: } 16: 17: public void draw(Graphics g) { 18: // Draw background image 19: g.drawImage(image, 0, 0, component); 20: } 21: }
Analysis |
|
It's time to take all the hard work that you've put into the sprite classes and see what it amounts to. Figure 24.4 shows a screen shot of the Sharks applet, which shows off the sprite classes you've worked so hard on all section. The complete source code, images, and executable classes for the Sharks applet are on the accompanying CD-ROM.
Figure 24.4 : The Sharks applet.
The Sharks applet uses a SpriteVector object to manage a group of hungry shark Sprite objects. This object, sv, is one of the Shark applet class's member variables, which follow:
private Image offImage, back; private Image[] leftShark = new Image[2]; private Image[] rightShark = new Image[2]; private Image[] clouds = new Image[2]; private Graphics offGrfx; private Thread animate; private MediaTracker tracker; private SpriteVector sv; private int delay = 83; // 12 fps private Random rand = new Random(System.currentTimeMillis());
The Image member variables in the Sharks class represent the offscreen buffer, the background image, the shark images, and some cloud images. The Graphics member variable, offGrfx, holds the graphics context for the offscreen buffer image. The Thread member variable, animate, is used to hold the thread where the animation takes place. The MediaTracker member variable, tracker, is used to track the various images as they are being loaded. The SpriteVector member variable, sv, holds the sprite vector for the applet. The integer (int) member variable, delay, determines the animation speed of the sprites. Finally, the Random member variable, rand, is used to generate random numbers throughout the applet.
Notice that the delay member variable is set to 83. The delay member variable specifies the amount of time (in milliseconds) that elapses between each frame of animation. You can determine the frame rate by inverting the value of delay, which results in a frame rate of about 12 frames per second (fps) in this case. This frame rate is pretty much the minimum rate required for fluid animation, such as sprite animation. You'll see how delay is used to establish the frame rate in a moment when you get into the details of the run method.
The Sharks class's init method loads all the images and registers them with the media tracker:
public void init() { // Load and track the images tracker = new MediaTracker(this); back = getImage(getCodeBase(), "Water.gif"); tracker.addImage(back, 0); leftShark[0] = getImage(getCodeBase(), "LShark0.gif"); tracker.addImage(leftShark[0], 0); leftShark[1] = getImage(getCodeBase(), "LShark1.gif"); tracker.addImage(leftShark[1], 0); rightShark[0] = getImage(getCodeBase(), "RShark0.gif"); tracker.addImage(rightShark[0], 0); rightShark[1] = getImage(getCodeBase(), "RShark1.gif"); tracker.addImage(rightShark[1], 0); clouds[0] = getImage(getCodeBase(), "SmCloud.gif"); tracker.addImage(clouds[0], 0); clouds[1] = getImage(getCodeBase(), "LgCloud.gif"); tracker.addImage(clouds[1], 0); }
Tracking the images is necessary because you want to wait until all the images have been loaded before you start the animation. The start and stop methods are standard thread- handler methods:
public void start() { if (animate == null) { animate = new Thread(this); animate.start(); } } public void stop() { if (animate != null) { animate.stop(); animate = null; } }
The start method is responsible for initializing and starting the animation thread. Likewise, the stop method stops the animation thread and cleans up after it.
Warning |
|
Listing 24.8. The Sharks class's run method.
1: public void run() { 2: try { 3: tracker.waitForID(0); 4: } 5: catch (InterruptedException e) { 6: return; 7: } 8: 9: // Create the sprite vector 10: sv = new SpriteVector(new ImageBackground(this, back)); 11: 12: // Create and add the sharks 13: for (int i = 0; i < 8; i++) { 14: boolean left = (rand.nextInt() % 2 == 0); 15: Point pos = new Point(Math.abs(rand.nextInt() % size().width), 16: (i + 1) * 4 + i * leftShark[0].getHeight(this)); 17: Sprite s = new Sprite(this, left ? leftShark: rightShark, 0, 1, 3, 18: pos, new Point((Math.abs(rand.nextInt() % 3) + 1) * (left ? -1: 1), 19: 0), 0, Sprite.BA_WRAP); 20: sv.add(s); 21: } 22: 23: // Create and add the clouds 24: Sprite s = new Sprite(this, clouds[0], new Point(Math.abs(rand.nextInt() 25: % size().width), Math.abs(rand.nextInt() % size().height)), new 26: Point(Math.abs(rand.nextInt() % 5) + 1, rand.nextInt() % 3), 1, 27: Sprite.BA_WRAP); 28: sv.add(s); 29: s = new Sprite(this, clouds[1], new Point(Math.abs(rand.nextInt() 30: % size().width), Math.abs(rand.nextInt() % size().height)), new 31: Point(Math.abs(rand.nextInt() % 5) - 5, rand.nextInt() % 3), 2, 32: Sprite.BA_WRAP); 33: sv.add(s); 34: 35: // Update everything 36: long t = System.currentTimeMillis(); 37: while (Thread.currentThread() == animate) { 38: // Update the sprites 39: sv.update(); 40: repaint(); 41: try { 42: t += delay; 43: Thread.sleep(Math.max(0, t - System.currentTimeMillis())); 44: } 45: catch (InterruptedException e) { 46: break; 47: } 48: } 49: }
Analysis |
|
After creating and adding the clouds, a while loop is entered that handles updating the SpriteVector and forcing the applet to repaint itself. By forcing a repaint, you are causing the applet to redraw the sprites in their newly updated states.
Before you move on, it's important to understand how the frame rate is controlled in the run method. The call to currentTimeMillis returns the current system time in milliseconds. You aren't really concerned with what absolute time this method is returning, because you are only using it here to measure relative time. After updating the sprites and forcing a redraw, the delay value is added to the time you just retrieved. At this point, you have updated the frame and calculated a time value that is delay milliseconds into the future. The next step is to tell the animation thread to sleep an amount of time equal to the difference between the future time value you just calculated and the present time.
This probably sounds pretty confusing, so let me clarify things a little. The sleep method is used to make a thread sleep for a number of milliseconds, as determined by the value passed in its only parameter. You might think that you could just pass delay to sleep and things would be fine. This approach technically would work, but it would have a certain degree of error. The reason is that a finite amount of time passes between updating the sprites and putting the thread to sleep. Without accounting for this lost time, the actual delay between frames wouldn't be equal to the value of delay. The solution is to check the time before and after the sprites are updated, and then reflect the difference in the delay value passed to the sleep method. And that's how the frame rate is managed!
The update method is where the sprites are actually drawn to the applet window:
public void update(Graphics g) { // Create the offscreen graphics context if (offGrfx == null) { offImage = createImage(size().width, size().height); offGrfx = offImage.getGraphics(); } // Draw the sprites sv.draw(offGrfx); // Draw the image onto the screen g.drawImage(offImage, 0, 0, null); }
The update method uses double buffering to eliminate flicker in the sprite animation. By using double buffering, you eliminate flicker and allow for speedier animations. The offImage member variable contains the offscreen buffer image used for drawing the next animation frame. The offGrfx member variable contains the graphics context associated with the offscreen buffer image.
In update, the offscreen buffer is first created as an Image object whose dimensions match those of the applet window. It is important that the offscreen buffer be exactly the same size as the applet window. The graphics context associated with the buffer is then retrieved using the getGraphics method of Image. After the offscreen buffer is initialized, all you really have to do is tell the SpriteVector object to draw itself to the buffer. Remember that the SpriteVector object takes care of drawing the background and all the sprites. This is accomplished with a simple call to SpriteVector's draw method. The offscreen buffer is then drawn to the applet window using the drawImage method.
Even though the update method takes care of drawing everything, it is still important to implement the paint method. As a matter of fact, the paint method is very useful in providing the user visual feedback regarding the state of the images used by the applet. Listing 24.9 shows the source code for paint.
Listing 24.9. The Sharks class's paint method.
1: public void paint(Graphics g) { 2: if ((tracker.statusID(0, true) & MediaTracker.ERRORED) != 0) { 3: // Draw the error rectangle 4: g.setColor(Color.red); 5: g.fillRect(0, 0, size().width, size().height); 6: return; 7: } 8: if ((tracker.statusID(0, true) & MediaTracker.COMPLETE) != 0) { 9: // Draw the offscreen image 10: g.drawImage(offImage, 0, 0, null); 11: } 12: else { 13: // Draw the title message (while the images load) 14: Font f1 = new Font("TimesRoman", Font.BOLD, 28), 15: f2 = new Font("Helvetica", Font.PLAIN, 16); 16: FontMetrics fm1 = g.getFontMetrics(f1), 17: fm2 = g.getFontMetrics(f2); 18: String s1 = new String("Sharks"), 19: s2 = new String("Loading images..."); 20: g.setFont(f1); 21: g.drawString(s1, (size().width - fm1.stringWidth(s1)) / 2, 22: ((size().height - fm1.getHeight()) / 2) + fm1.getAscent()); 23: g.setFont(f2); 24: g.drawString(s2, (size().width - fm2.stringWidth(s2)) / 2, 25: size().height - fm2.getHeight() - fm2.getAscent()); 26: } 27: }
Analysis |
|
Figure 24.5 : The Sharks applet while the images are loading.
If an error occurs while loading one of the images, the paint method displays a red rectangle over the entire applet window area. If the images have finished loading, paint just draws the latest offscreen buffer to the applet window. If the images haven't finished loading, paint displays the title of the applet and a message stating that the images are still loading (see Figure 24.5). Displaying the title and status message consists of creating the appropriate fonts and centering the text within the applet window.
That's all it takes to get the sprite classes working together. It might seem like a lot of code at first, but think about all that the applet is undertaking. The applet is responsible for loading and keeping track of all the images used by the sprites, as well as the background and offscreen buffer. If the images haven't finished loading, or if an error occurs while loading, the applet has to notify the user accordingly. Additionally, the applet is responsible for maintaining a consistent frame rate and drawing the sprites using double buffering. Even with these responsibilities, the applet is still benefiting a great deal from the functionality provided by the sprite classes.
You can use this applet as a template applet for other applets you create that use the sprite classes. You now have all the functionality required to manage both cast- and frame-based animation, as well as provide support for interactivity among sprites via collision detection.
In today's lesson you have learned all about animation, including the two major types of animation: frame based and cast based. Adding to this theory, you have learned that sprite animation is where the action really is. You have seen firsthand how to develop a powerful duo of sprite classes for implementing sprite animation, including a few support classes to make things easier. You have put the sprite classes to work in a sample applet that involves relatively little additional overhead.
You now have all you need to start creating your own Java sprite animations with ease. If that's not enough for you, just wait until tomorrow's lesson, which deals with another advanced graphics topic: image filters.
Q: |
What's the big deal with sprites? |
A: |
The big deal is that sprites provide a very flexible approach to implementing animation. Additionally, using sprites you can take advantage of both fundamental types of animation: frame-based animation and cast-based animation. |
Q: |
What exactly is Z-order, and do I really need it? |
A |
Z-order is the depth of a sprite relative to other sprites; sprites with higher Z-order values appear to be on top of sprites with lower Z-order values. You only need Z-order if you have sprites that overlap each other, in which case Z-order will determine which one conceals the other. |
Q: |
Why bother with the different types of collision detection? |
A: |
The different types of collision detection (rectangle, shrunken rectangle, and image data) provide different trade-offs in regard to performance and accuracy. Rectangle and shrunken rectangle collision detection provide a very high-performance solution, but with moderate to poor accuracy. Image data-collision detection is perfect when it comes to accuracy, but it can bring your applet to its knees in the performance department, not to mention give you a headache trying to make it work. |
Q: |
Why do I need the SpriteVector class? Isn't the Sprite class enough? |
A: |
The Sprite class is nice, but it represents only a single sprite. To enable multiple sprites to interact with each other, you must have a second entity that acts as a storage unit for the sprites. The SpriteVector class solves this problem by doubling as a container for all the sprites as well as a means of detecting collisions between sprites. |