This chapter
is taken from book "Programming Windows Phone 7" by Charles Petzold published by
Microsoft press.
http://www.charlespetzold.com/phone/index.html
Learning how to use XNA to move text around the screen would provide
a leg up in the art of moving regular bitmap sprites. This relationship
becomes very obvious when you begin examining the Draw methods supported by the SpriteBatch.
The Draw methods have almost the same arguments as DrawString but work with bitmaps rather than text. In this
article
I'll examine techniques for moving and turning sprites, particularly
along curves.
The Draw Variants
Both the Game
class and the SpriteBatch class have methods named Draw.
Despite the identical names, the two methods are not genealogically
related through a class hierarchy. In a class derived from Game you override the Draw method so that you can call the Draw method of SpriteBatch.
This latter Draw
method comes in seven different versions. The
simplest one is:
Draw(Texture2D texture, Vector2 position, Color color)
The next two versions of Draw have five additional arguments that you'll
recognize from the
DrawString methods:
Draw(Texture2D texture, Vector2 position, Rectangle? source, Color color,
float rotation, Vector2 origin, float scale, SpriteEffects effects, float
depth)
Draw(Texture2D texture, Vector2 position, Rectangle? source, Color color,
float rotation, Vector2 origin, Vector2 scale, SpriteEffects effects, float
depth)
As with DrawString,
the rotation angle is in radians, measured clockwise. The origin
is a point in the texture that is to be aligned with the position
argument. You can scale uniformly with a single
float or differently in
the horizontal and vertical directions with a
Vector2. The
SpriteEffects
enumeration lets you flip an image horizontally or vertically to get its mirror
image. The last argument allows overriding the defaults for layering multiple
textures on the screen.
Within the Draw
method of your Game class, you use the SpriteBatch object like so:
spriteBatch.Begin(); spriteBatch.Draw ... spriteBatch.End();
Within the Begin and
End calls, you can have any number of calls to Draw and
DrawString.
The Draw calls can reference the same texture. You can also have multiple
calls to Begin followed by
End with Draw and
DrawString in between.
A Hello Program
This time I'll compose a very blocky rendition of the word "HELLO"
using two different bitmaps—a vertical bar and a horizontal bar. The
letter "H" will be two vertical bars and one horizontal bar. The "O" at
the end will look like a rectangle.
And then, when you tap the screen, all 15 bars will fly apart in random
directions and then come back together. Sound like fun?
I'm going to use a little class called
SpriteInfo to keep track of the 15 textures required for forming the
text. If you're creating the project from scratch, right-click the project name,
and select Add and then New Item (or select Add New Item from the main Project
menu). From the dialog box select Class and give it the name SpriteInfo.cs.
namespace FlyAwayHello
{
public class
SpriteInfo
{
public static
float InterpolationFactor {
set; get; }
public Texture2D Texture2D {
protected set;
get; }
public Vector2 BasePosition {
protected set;
get; }
public Vector2 PositionOffset {
set;
get; }
public float
MaximumRotation { set; get; }
public SpriteInfo(Texture2D
texture2D, int x, int
y)
{
Texture2D = texture2D;
BasePosition =
new Vector2(x, y);
}
public
Vector2 Position
{
get
{
return BasePosition +
InterpolationFactor * PositionOffset;
}
}
public float
Rotation
{
get
{
return InterpolationFactor *
MaximumRotation;
}
}
}
}
The required constructor stores a
Texture2D along with positioning information. This
is how each sprite is initially positioned to spell out the word "HELLO." Later
in the "fly away" animation, the program sets the PositionOffset and
MaximumRotation properties. The Position and Rotation properties perform
calculations based on the static InterpolationFactor,
which can range from 0 to 1.
Here are the fields of the
Game1
class:
public
class Game1 :
Microsoft.Xna.Framework.Game
{
static readonly
TimeSpan ANIMATION_DURATION =
TimeSpan.FromSeconds(5);
const int
CHAR_SPACING = 5;
GraphicsDeviceManager graphics;
SpriteBatch spriteBatch;
Viewport viewport;
List<SpriteInfo>
spriteInfos = new
List<SpriteInfo>();
Random rand =
new Random();
bool isAnimationGoing;
TimeSpan animationStartTime;
...
}
This program initiates an animation only when the user
taps the screen, so I'm handling the timing just a little differently than in
earlier programs, as I'll demonstrate in the
Update method.
The LoadContent
method loads the two
Texture2D objects
using the same generic Load
method that previous programs used to load a
SpriteFont. Enough
information is now available to create and initialize all
SpriteInfo objects:
protected
override void LoadContent()
{
spriteBatch = new
SpriteBatch(GraphicsDevice);
viewport = this.GraphicsDevice.Viewport;
Texture2D horzBar = Content.Load<Texture2D>("HorzBar");
Texture2D vertBar = Content.Load<Texture2D>("VertBar");
int x = (viewport.Width - 5 *
horzBar.Width - 4 * CHAR_SPACING) / 2;
int y = (viewport.Height -
vertBar.Height) / 2;
int xRight = horzBar.Width -
vertBar.Width;
int yMiddle = (vertBar.Height -
horzBar.Height) / 2;
int yBottom = vertBar.Height -
horzBar.Height;
// H
spriteInfos.Add(new
SpriteInfo(vertBar, x, y));
spriteInfos.Add(new
SpriteInfo(vertBar, x + xRight, y));
spriteInfos.Add(new
SpriteInfo(horzBar, x, y + yMiddle));
// E
x += horzBar.Width + CHAR_SPACING;
spriteInfos.Add(new
SpriteInfo(vertBar, x, y));
spriteInfos.Add(new
SpriteInfo(horzBar, x, y));
spriteInfos.Add(new
SpriteInfo(horzBar, x, y + yMiddle));
spriteInfos.Add(new
SpriteInfo(horzBar, x, y + yBottom));
// LL
for (int
i = 0; i < 2; i++)
{
x += horzBar.Width + CHAR_SPACING;
spriteInfos.Add(new
SpriteInfo(vertBar, x, y));
spriteInfos.Add(new
SpriteInfo(horzBar, x, y + yBottom));
}
// O
x += horzBar.Width + CHAR_SPACING;
spriteInfos.Add(new
SpriteInfo(vertBar, x, y));
spriteInfos.Add(new
SpriteInfo(horzBar, x, y));
spriteInfos.Add(new
SpriteInfo(horzBar, x, y + yBottom));
spriteInfos.Add(new
SpriteInfo(vertBar, x + xRight, y));
}
The Update
method is responsible for keeping the animation going. If the isAnimationGoing
field is false, it checks for a new finger pressed on the screen.
protected
override void
Update(GameTime gameTime)
{
if (GamePad.GetState(PlayerIndex.One).Buttons.Back
== ButtonState.Pressed)
this.Exit();
if (isAnimationGoing)
{
TimeSpan animationTime =
gameTime.TotalGameTime - animationStartTime;
double fractionTime = (double)animationTime.Ticks
/ ANIMATION_DURATION.Ticks;
if (fractionTime >= 1)
{
isAnimationGoing = false;
fractionTime = 1;
}
SpriteInfo.InterpolationFactor
= (float)Math.Sin(Math.PI
* fractionTime);
}
else
{
TouchCollection
touchCollection = TouchPanel.GetState();
bool atLeastOneTouchPointPressed
= false;
foreach (TouchLocation
touchLocation in touchCollection)
atLeastOneTouchPointPressed |=
touchLocation.State ==
TouchLocationState.Pressed;
if (atLeastOneTouchPointPressed)
{
foreach (SpriteInfo
spriteInfo in spriteInfos)
{
float r1 = (float)rand.NextDouble()
- 0.5f;
float r2 = (float)rand.NextDouble()
- 0.5f;
float r3 = (float)rand.NextDouble();
spriteInfo.PositionOffset = new
Vector2(r1 * viewport.Width,
r2 *
viewport.Height);
spriteInfo.MaximumRotation = 2 * (float)Math.PI
* r3;
}
animationStartTime = gameTime.TotalGameTime;
isAnimationGoing = true;
}
}
base.Update(gameTime);
}
When the animation begins, the
animationStartTime is
set from the TotalGameTime
property of GameTime.
During subsequent calls, Update
compares that value with the new
TotalGameTime and
calculates an interpolation factor. The
InterpolationFactor property of
SpriteInfo is static
so it need be set only once to affect all the
SpriteInfo instances.
The Draw method
loops through the SpriteInfo
objects to access the
Position and
Rotation properties:
protected
override void
Draw(GameTime gameTime)
{
GraphicsDevice.Clear(Color.Navy);
spriteBatch.Begin();
foreach (SpriteInfo
spriteInfo in spriteInfos)
{
spriteBatch.Draw(spriteInfo.Texture2D, spriteInfo.Position,
null,
Color.Lerp(Color.Blue,
Color.Red,
SpriteInfo.InterpolationFactor),
spriteInfo.Rotation, Vector2.Zero,
1, SpriteEffects.None, 0);
}
spriteBatch.End();
base.Draw(gameTime);
}
The Draw
call also uses SpriteInfo.InterpolationFactor to interpolate between blue and
red for coloring the bars. Notice that the Color structure also has a Lerp
method. The text is normally blue but
changes to red as the pieces fly apart.
Driving Around the Block
For the remainder of this article I want to focus on
techniques to maneuver a sprite around some kind of path. To make it more
"realistic," I commissioned my wife Deirdre to make a little race-car in Paint:
This image is stored as the file car.png as part of the
project's content. The first project is called CarOnRectangularCourse and
demonstrates a rather clunky approach to driving a car around the perimeter of
the screen. Here are the fields:
public
class Game1 :
Microsoft.Xna.Framework.Game
{
const float
SPEED = 100; // pixels per second
GraphicsDeviceManager
graphics;
SpriteBatch spriteBatch;
Texture2D car;
Vector2 carCenter;
Vector2[] turnPoints =
new Vector2[4];
int sideIndex = 0;
Vector2 position;
float rotation;
....
}
The turnPoints
array stores the four points near the corners of the
display where the car makes a sharp turn. Calculating these points is one of the
primary activities of the LoadContent
method, which also loads the
Texture2D and
initializes other fields:
protected
override void
LoadContent()
{
spriteBatch = new
SpriteBatch(GraphicsDevice);
car = this.Content.Load<Texture2D>("car");
carCenter = new Vector2(car.Width / 2,
car.Height / 2);
float margin = car.Width;
Viewport viewport =
this.GraphicsDevice.Viewport;
turnPoints[0] = new
Vector2(margin, margin);
turnPoints[1] = new
Vector2(viewport.Width - margin, margin);
turnPoints[2] = new
Vector2(viewport.Width - margin,
viewport.Height - margin);
turnPoints[3] = new
Vector2(margin, viewport.Height - margin);
position = turnPoints[0];
rotation = MathHelper.PiOver2;
}
I use the carCenter
field as the
origin argument to the
Draw method, so that's
the point on the car that aligns with a point on the course defined by the four
members of the turnPoints
array. The margin
value makes this course one car width from the edge
of the display; hence the car is really separated from the edge of the display
by half its width.
I described this program as "clunky" and the
Update method proves
it:
protected
override void
Update(GameTime gameTime)
{
if (GamePad.GetState(PlayerIndex.One).Buttons.Back
== ButtonState.Pressed)
this.Exit();
float pixels = SPEED * (float)gameTime.ElapsedGameTime.TotalSeconds;
switch (sideIndex)
{
case 0:
// top
position.X += pixels;
if (position.X >
turnPoints[1].X)
{
position.X = turnPoints[1].X;
position.Y = turnPoints[1].Y + (position.X -
turnPoints[1].X);
rotation = MathHelper.Pi;
sideIndex = 1;
}
break;
case 1:
// right
position.Y += pixels;
if (position.Y >
turnPoints[2].Y)
{
position.Y = turnPoints[2].Y;
position.X = turnPoints[2].X - (position.Y -
turnPoints[2].Y);
rotation = -MathHelper.PiOver2;
sideIndex = 2;
}
break;
case 2:
// bottom
position.X -= pixels;
if (position.X <
turnPoints[3].X)
{
position.X = turnPoints[3].X;
position.Y = turnPoints[3].Y + (position.X -
turnPoints[3].X);
rotation = 0;
sideIndex = 3;
}
break;
case 3:
// left
position.Y -= pixels;
if (position.Y <
turnPoints[0].Y)
{
position.Y = turnPoints[0].Y;
position.X = turnPoints[0].X - (position.Y -
turnPoints[0].Y);
rotation = MathHelper.PiOver2;
sideIndex = 0;
}
break;
}
base.Update(gameTime);
}
This is the type of code that screams out "There's got to be a better way"
Elegant it is not, and not very versatile either. But before I take a stab at a
more flexible approach, here's the entirely predictable Draw method that
incorporates the updated position and rotation values calculated during Update:
protected
override void
Draw(GameTime gameTime)
{
GraphicsDevice.Clear(Color.Blue);
spriteBatch.Begin();
spriteBatch.Draw(car, position, null,
Color.White, rotation,
carCenter, 1,
SpriteEffects.None, 0);
spriteBatch.End();
base.Draw(gameTime);
}
A Generalized Curve Solution
For movement along curves that are not quite convenient to express in
parametric equations, XNA itself provides a generalized solution based
around the Curve
and CurveKey classes defined in the Microsoft.Xna.Framework
namespace.
The Curve class contains a property named Keys of
type CurveKeyCollection,
a collection of CurveKey
objects. Each CurveKey object allows you to specify a number pair of the form (Position,
Value).
Both the Position and
Value properties are of type float.
Then you pass a position to the Curve
method Evaluate,
and it returns an interpolated value.
Suppose you want the car to go around a path that looks like an infinity
sign, and let's assume that we're going to approximate the infinity sign with
two adjacent circles. (The technique I'm going to show you will allow you to
move those two circles apart at a later time if you'd like.)
If the radius of each circle is 1 unit, the entire
figure is 4 units wide and 2 units tall. The X coordinates of these dots (going
from left to right) are the values 0, 0.293, 1, 0.707, 2, 2.293, 3, 3.707, and
4, and the Y coordinates (going from top to bottom) are the values 0, 0.293, 1,
1.707, and 2. The value 0.707 is simply the sine and
cosine of 45 degrees, and 0.293 is one minus that value.
Let's begin at the point on the far
left, and let's travel clockwise around the first circle. At the center of the
figure, let's switch to going counter-clockwise around the second circle to form
an infinity sign and finish with the same dot we started with. The X values are:
0, 0.293, 1, 1.707, 2, 2.293, 3, 3.707,
4, 3.707, 3, 2.293, 2, 1.707, 1, 0.293, 0
If we're using values of
t ranging from 0 to 1
to drive around the infinity sign, then the first value corresponds to a
t of 0, and the last
(which is the same) to a t
of 1. For each value,
t is incremented by 1/16 or 0.0625. The Y values
are:
1, 0.293, 0, 0.293, 1, 1.707, 2, 1.707,
1, 0.293, 0, 0.293, 1, 1.707, 2, 1.707, 1
We are now ready for some coding. Here are the fields
for the CarOnInfinityCourse project:
namespace
CarOnInfinityCourse
{
public class
Game1 : Microsoft.Xna.Framework.Game
{
const float
SPEED = 0.1f; // laps per second
GraphicsDeviceManager
graphics;
SpriteBatch spriteBatch;
Viewport viewport;
Texture2D car;
Vector2 carCenter;
Curve xCurve =
new Curve();
Curve yCurve =
new Curve();
Vector2 position;
float rotation;
public Game1()
{
graphics = new
GraphicsDeviceManager(this);
Content.RootDirectory = "Content";
// Frame rate is 30 fps by default for
Windows Phone.
TargetElapsedTime = TimeSpan.FromTicks(333333);
}
protected
override void Initialize()
{
float[] xValues = { 0, 0.293f,
1, 1.707f, 2, 2.293f, 3, 3.707f,
4, 3.707f, 3, 2.293f, 2, 1.707f, 1, 0.293f
};
float[] yValues = { 1, 0.293f,
0, 0.293f, 1, 1.707f, 2, 1.707f,
1, 0.293f, 0, 0.293f, 1, 1.707f, 2, 1.707f
};
for (int
i = -1; i < 18; i++)
{
int index = (i + 16) % 16;
float t = 0.0625f * i;
xCurve.Keys.Add(new
CurveKey(t, xValues[index]));
yCurve.Keys.Add(new
CurveKey(t, yValues[index]));
}
xCurve.ComputeTangents(CurveTangent.Smooth);
yCurve.ComputeTangents(CurveTangent.Smooth);
base.Initialize();
}
protected
override void LoadContent()
{
spriteBatch = new
SpriteBatch(GraphicsDevice);
viewport = this.GraphicsDevice.Viewport;
car = this.Content.Load<Texture2D>("Car");
carCenter = new Vector2(car.Width / 2,
car.Height / 2);
}
protected
override void UnloadContent()
{
}
protected
override void Update(GameTime
gameTime)
{
if (GamePad.GetState(PlayerIndex.One).Buttons.Back
== ButtonState.Pressed)
this.Exit();
float t = (SPEED * (float)gameTime.TotalGameTime.TotalSeconds)
% 1;
float x = GetValue(t,
true);
float y = GetValue(t,
false);
position = new
Vector2(x, y);
rotation = MathHelper.PiOver2
+ (float)
Math.Atan2(GetValue(t +
0.001f, false) - GetValue(t - 0.001f,
false),
GetValue(t + 0.001f,
true) - GetValue(t - 0.001f, true));
base.Update(gameTime);
}
float GetValue(float
t, bool isX)
{
if (isX)
return xCurve.Evaluate(t) *
(viewport.Width - 2 * car.Width) / 4 + car.Width;
return yCurve.Evaluate(t) * (viewport.Height
- 2 * car.Width) / 2 + car.Width;
}
protected
override void Draw(GameTime
gameTime)
{
GraphicsDevice.Clear(Color.Blue);
spriteBatch.Begin();
spriteBatch.Draw(car, position, null,
Color.White, rotation,
carCenter, 1,
SpriteEffects.None, 0);
spriteBatch.End();
base.Draw(gameTime);
}
}
}
If you want the Curve class to calculate the tangents used for calculating
the spline (as I did in this program) it is essential to give the class
sufficient points, not only beyond the range of points you wish to interpolate
between, but enough so that these calculated tangents are more or less accurate.
I originally tried defining the infinity course with points on the two circles
every 90 degrees, and it didn't work well at all.