Work with Textures and Sprites in Windows Phone 7


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.

one.gif

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:

two.gif

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.)

three.gif

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.


Similar Articles