Drawing Animation Frames
Let’s dig into the nitty-gritty of sprite animation. Most beginners create an animation sequence by loading each frame from a separate bitmap file and storing the frames in an array or a list. This approach has the distinct disadvantage of requiring many files for even a basic animation for a walking character or some other game object, which often involves 50 to 100 or more frames. Although it can be done, that is a slow and error-prone way to handle animation. Instead, it is preferable to use an animation sheet.
Preparing the Animation
A sprite animation sheet is a single bitmap containing many frames arranged in rows and columns, as shown in Figure 14.1. In this sprite sheet (of an animated asteroid), there are eight columns across and 64 frames overall. The animation here was rendered from a 3D model, which is why it looks so great when animated on the screen! One question that might come up is, where do you get animations? Most game art is created by a professional artist specifically for one game, and then it is never used again (usually because the game studio owns the assets). There are, however, several good sources of free artwork online, such as Reiner’s Tilesets (http:// www.reinerstilesets.de).
SpriteBatch doesn’t care whether your sprite’s source image uses a color key or an alpha channel for transparency; it just renders the image. If you have an image with an alpha channel, like a TGA or PNG, then it will be rendered with any existing alpha, with translucent blending of the background. This is the technique used to render a sprite with transparency in XNA. Looking at sprite functionality at a lower level, you can tell the sprite renderer (SpriteBatch) what color you want to use when drawing the image, which was the focus of the preceding two hours on performing color and transform animations.
The bitmap file should have an alpha channel if you want to use transparency (which is almost always the case). Most artists prefer to define their own translucent pixels for best results rather than leaving it to chance in the hands of a programmer. The main reason to use alpha rather than color-key transparency is better quality. An alpha channel can define pixels with shades of translucency. In contrast, a color key is an all-or-nothing, on/off setting with a solid edge, because such an image will have discrete pixels. You can do alpha blending at runtime to produce special effects (such as a particle emitter), but for maximum quality, it’s best to prepare artwork in advance. See Figure 14.2.
Rather than using a black border around a color-keyed sprite (the old-school way of highlighting a sprite), an artist will usually blend a border around a sprite’s edges using an alpha level for partial translucency. To do that, you must use a file format that supports 32-bit RGBA images. TGA and PNG files both support an alpha channel and XNA supports them. The PNG format is a good choice that you may consider using most of the time because it has wide support among all graphic editing tools.
Calculating Frame Position
Assuming that we have an animation sheet like the asteroid animation shown in Figure 14.2, we can begin exploring ways to draw a single frame. This will require some changes to the Sprite class, which currently just uses the dimensions of the loaded image. That will have to change to reflect the dimensions of a single frame, not the whole image. To calculate the position of a frame in the sheet, we have to know the width and height of a single frame. Then, beginning at the upper left, we can calculate how to move right and down the correct number of frames. Figure 14.3 shows a typical sheet with the columns and rows labeled for easy reference.
The more important of the two is the row calculation, so we’ll do that one first. To make this calculation, you need to know how many frames there are across from left to right. These are the columns. (See Figure 14.4.) Here is the formula for calculating the row or Y position of a frame number on the sprite sheet:
[code]
Y = ( Frame_Number / Columns ) * Frame_Height
[/code]
To calculate the column or X position of a frame number on the sprite sheet, a similar- looking calculation is done, but the result is quite different:
[code]
X = ( Frame_Number % Columns ) * Frame_Width
[/code]
Note that the math operator is not division. The percent symbol (%) is the modulus operator in C#. Modulus is similar to division, but instead of returning the quotient (or answer), it returns the remainder! Why do we care about the remainder? That represents the X position of the frame! Here’s the answer: because X is the extra or leftover amount after the division. Recall that the formula for calculating Y gave us a distinct integer quotient. We want to use the same variables, but modulus rather than division gives us the partial column in the row, which represents the X value.
Drawing One Frame
Equipping ourselves with these formulas, we can write the code to draw a frame from a sprite sheet onto the screen. First, we’ll create a Rectangle to represent the source frame:
[code]
Rectangle source = new Rectangle();
source.X = (frame % columns) * width;
source.Y = (frame / columns) * height;
source.Width = width;
source.Height = height;
[/code]
Next, we’ll use the Rectangle when calling SpriteBatch.Draw(), using one of the overloads of the method that allows use of a source rectangle. We can retain the existing rotation, origin, and scale parameters while still drawing just a single frame.
[code]
spriteBatch.Draw( image, //source Texture2D
position, //destination position
source, //source Rectangle
Color.White, //target color
rotation, //rotation value
origin, //pivot for rotation
scale, //scale factor
SpriteEffects.None, //flip or mirror effects
0.0f ); //z-index order
[/code]
Creating the Frame Animation Demo
The only way to really get experience with animation is to practice by writing code. One fairly common mistake that results in an animated sprite not showing up is to forget the frame size property. This must be set after a bitmap file is loaded or the image property is set to an outside Texture2D object. The Sprite.size property is a Vector2 that must be set to the width and height of a single frame. Forgetting to do this after loading the bitmap will result in the animation not showing up correctly.
Sprite Class Changes
Some rather dramatic changes must be made to the Sprite class to support frame animation. Now, the reason for most of the changes involves the sprite sheet image. Previously, the whole image was used, for drawing, for calculating scale, and so on. Now, that code has to be changed to account for the size of just one frame, not the whole image.
Modifying the Sprite Class
- First up, we have some new variables in the Sprite class. These can be added to the top of the class with the other variables:
[code]
private double startTime;
public Vector2 size;
public int columns, frame, totalFrames;
[/code] - In the Sprite constructor, the new variables are initialized after all the others. The columns and totalFrames variables are crucial to drawing simple sprites when no animation is being used. In other words, they’re needed to preserve compatibility with code that used the Sprite class before this point. By setting columns to 1, we tell the Draw() method to treat the image as if there is just one column. Likewise, setting totalFrames to 1 ensures that just that one frame is drawn, even if no animation is used. A flag will be used in the Draw() method just to make sure null errors don’t occur, but these initialized values should take care of that as well.
[code]
size.X = size.Y = 0;
columns = 1;
frame = 0;
totalFrames = 1;
startTime = 0;
[/code] - Next up are two helper properties that make using Sprite a bit easier, by exposing the X and Y properties of position. This is a convenience rather than a required change, but it is very helpful in the long term.
[code]
public float X
{
get
{
return position.X;
}
set
{
position.X = value;
}
}
public float Y
{
get
{
return position.Y;
}
set
{
position.Y = value;
}
}
[/code] - Next, we need to review the Load() method again for reference, just to note what is being initialized at this point. Pay special attention to origin and size, because they are involved in a single frame being drawn correctly. Notice that origin is initialized with the full size of the image. When using a sprite sheet, origin must be reset after the image is loaded for drawing to work correctly!
[code]
public bool Load(string assetName)
{
try
{
image = p_content.Load<Texture2D>(assetName);
origin = new Vector2(image.Width / 2, image.Height / 2);
}
catch (Exception) { return false; }
size.X = image.Width;
size.Y = image.Height;
return true;
}
[/code] - Next, make the required changes to the Sprite.Draw() method. Quite a dramatic change has come over Draw(), transforming it into a fully featured animation rendering routine with support for single images or sprite sheet animations. This is a frame animation workhorse—this is where all the “good stuff” is happening.
[code]
public void Draw()
{
if (!visible) return;
if (totalFrames > 1)
{
Rectangle source = new Rectangle();
source.X = (frame % columns) * (int)size.X;
source.Y = (frame / columns) * (int)size.Y;
source.Width = (int)size.X;
source.Height = (int)size.Y;
p_spriteBatch.Draw(image, position, source, color,
rotation, origin, scale, SpriteEffects.None, 0.0f);
}
else
{
p_spriteBatch.Draw(image, position, null, color, rotation,
origin, scaleV, SpriteEffects.None, 0.0f);
}
}
[/code] - Next, make a minor improvement to the Rotate() method to speed it up. If no rotation is happening, the calculations are skipped.
[code]
public void Rotate()
{
if (velocityAngular != 0.0f)
{
rotation += velocityAngular;
if (rotation > Math.PI * 2)
rotation -= (float)Math.PI * 2;
else if (rotation < 0.0f)
rotation = (float)Math.PI * 2 – rotation;
}
}
[/code] - Next, we must make minor modifications to the Boundary() method to account for the size of a single frame, rather than using the whole image. The old lines have been commented out; note the new calculations for halfw and halfh.
[code]
public Rectangle Boundary()
{
//int halfw = (int)((float)(image.Width / 2) * scaleV.X);
//int halfh = (int)((float)(image.Height / 2) * scaleV.Y);
int halfw = (int)((float)(size.X / 2) * scaleV.X);
int halfh = (int)((float)(size.Y / 2) * scaleV.Y);
return new Rectangle(
(int)position.X – halfw,
(int)position.Y – halfh,
halfw * 2,
halfh * 2);
}
[/code] - Next, we’ll add two new overloads of Animate() to support frame animation. It might be less confusing to call these FrameAnimate() if you want, because they share the same name as the previous version of Animate() that did color and transform animations. The difference between those and frame animation is that the latter requires parameters, either the elapsed time or the actual frame range, time, and animation speed. First, let’s review the existing method (no changes required):
[code]
public void Animate()
{
if (animations.Count == 0) return;
foreach (Animation anim in animations)
{
if (anim.animating)
{
color = anim.ModifyColor(color);
position = anim.ModifyPosition(position);
rotation = anim.ModifyRotation(rotation);
scaleV = anim.ModifyScale(scaleV);
}
else
{
animations.Remove(anim);
return;
}
}
}
[/code]
Okay, now here are the new methods for frame animation that you can add to the source code. The elapsedTime parameter helps the animation code to run at the correct speed. Note that the simple version calls the more complex version with default values for convenience. If you want to draw just a subset of an animation set, this second version of Animate() will do that.
[code]
public void Animate(double elapsedTime)
{
Animate(0, totalFrames-1, elapsedTime, 30);
}
public void Animate(int startframe, int endframe, double elapsedTime,
double speed)
{
if (totalFrames <= 1) return;
startTime += elapsedTime;
if (startTime > speed)
{
startTime = 0;
if (++frame > endframe) frame = startframe;
}
}
[/code]
That concludes the changes to the Sprite class, so now we can go into the example.
Sample Program
The example for this hour draws a bunch of animated asteroid sprites that move across the screen (in the usual landscape mode). But this is no simple demo—there is rudimentary gameplay. A small spaceship has been added. Tap above the ship to move it up, or below the ship to move it down, and avoid the asteroids! (See Figure 14.5.)
LISTING 14.1 Source Code for the Frame Animation Demo Program
[code]
public class Game1 : Microsoft.Xna.Framework.Game
{
GraphicsDeviceManager graphics;
SpriteBatch spriteBatch;
TouchLocation oldTouch;
Random rand;
SpriteFont font;
List<Sprite> objects;
Sprite fighter;
int score = 0;
int hits = 0;
public Game1()
{
graphics = new GraphicsDeviceManager(this);
Content.RootDirectory = “Content”;
TargetElapsedTime = TimeSpan.FromTicks(333333);
oldTouch = new TouchLocation();
}
protected override void Initialize()
{
base.Initialize();
}
protected override void LoadContent()
{
rand = new Random();
spriteBatch = new SpriteBatch(GraphicsDevice);
font = Content.Load<SpriteFont>(“WascoSans”);
//create object list
objects = new List<Sprite>();
//create fighter sprite
fighter = new Sprite(Content, spriteBatch);
fighter.Load(“fighter”);
fighter.position = new Vector2(40, 240);
fighter.rotation = MathHelper.ToRadians(90);
//create asteroid sprites
for (int n = 0; n < 20; n++)
{
Sprite ast = new Sprite(Content, spriteBatch);
ast.Load(“asteroid”);
ast.size = new Vector2(60, 60);
ast.origin = new Vector2(30, 30);
float x = 800 + (float)rand.Next(800);
float y = (float)rand.Next(480);
ast.position = new Vector2(x, y);
ast.columns = 8;
ast.totalFrames = 64;
ast.frame = rand.Next(64);
x = (float)(rand.NextDouble() * rand.Next(1, 10));
y = 0;
ast.velocityLinear = new Vector2(-x, y);
objects.Add(ast);
}
}
protected override void Update(GameTime gameTime)
{
if (GamePad.GetState(PlayerIndex.One).Buttons.Back ==
ButtonState.Pressed)
this.Exit();
//get state of touch input
TouchCollection touchInput = TouchPanel.GetState();
if (touchInput.Count > 0)
{
TouchLocation touch = touchInput[0];
if (touch.State == TouchLocationState.Pressed)
{
if (touch.Position.Y < fighter.Y )
{
fighter.velocityLinear.Y -= 1.0f;
}
else if (touch.Position.Y >= fighter.Y)
{
fighter.velocityLinear.Y += 1.0f;
}
}
oldTouch = touch;
}
//gradually reduce velocity
if (fighter.velocityLinear.Y < 0)
fighter.velocityLinear.Y += 0.05f;
else if (fighter.velocityLinear.Y > 0)
fighter.velocityLinear.Y -= 0.05f;
//keep fighter in screen bounds
if (fighter.Y < -32)
{
fighter.Y = -32;
fighter.velocityLinear.Y = 0;
}
else if (fighter.Y > 480-32)
{
fighter.Y = 480-32;
fighter.velocityLinear.Y = 0;
}
fighter.Move();
//update all objects
foreach (Sprite spr in objects)
{
spr.Rotate();
spr.Move();
//wrap asteroids around screen
if (spr.X < -60)
{
spr.X = 800;
score++;
}
//look for collision with fighter
if (fighter.Boundary().Intersects(spr.Boundary()))
{
hits++;
spr.X = 800;
}
}
base.Update(gameTime);
}
protected override void Draw(GameTime gameTime)
{
GraphicsDevice.Clear(Color.Black);
spriteBatch.Begin();
foreach (Sprite spr in objects)
{
spr.Animate(gameTime.ElapsedGameTime.Milliseconds);
spr.Draw();
}
fighter.Draw();
spriteBatch.DrawString(font, “Score:” + score.ToString() +
“, Hits:” + hits.ToString(), Vector2.Zero, Color.White);
spriteBatch.End();
base.Draw(gameTime);
}
}
[/code]