Microsoft XNA Game Studio 3.0 Unleashed  GameDev.net

Microsoft XNA Game Studio 3.0 Unleashed
Chapter 22: Creating a 3D Game
by Chad Carter


ADVERTISEMENT

Creating the Tunnel Vision Game

The game is set in outer space. The tunnel to our space station is being attacked, so we need to defend the tunnel and not let our enemies breach the opening. We have missiles that we can fire. Fortunately, the enemies do not have any. They simply attack in swarms, which means we need to take swift action to destroy them.

Creating the Game States

To get started, we need to make a copy of our GameStateDemo from Chapter 17, “Finite State Machines and Game State Management.” We want to use the latest XELibrary from the last chapter, and we need to add the project to our solution because we will be making a modification to it a little later.

Rename Game1.cs to TunnelVision.cs. We can also change the name of this class and the namespace to TunnelVision. Figure 22.1 shows how we can rename our namespace through the IDE. This will modify all our source files for us. We also need to rename our Game1 class to TunnelVision.


FIGURE 22.1 Visual Studio allows renaming the namespace through the IDE.

Adding a Skybox to Our Game

Let’s add a skybox to our world. Find the skybox.tga file and add it to the Skyboxes folder in the Content project. We need to add a skybox content processor to our Content project as well as select SkyboxProcessor as the content processor for the skybox texture. Then we can add the following private member field to our game:
private Skybox skybox;
Next, we can load the skybox in our LoadContent method:
skybox = Content.Load<Skybox>(@”Skyboxes\skybox”);
To finish up our skybox, we only need to draw it. We can add the following statement to the Draw method:
skybox.Draw(Camera.View, Camera.Projection, Matrix.CreateScale(1000));

Compiling the Game

While we are in our Draw method, we can remove the Begin and End methods for SpriteBatch because each game component will need to call its own. We need to leave the base.Draw method, of course.

Let’s add a camera to our game. We are going to change this camera to one that is more suitable for our game, but for now we can just reference the normal Camera class. We need to change our private member field camera to the public member field Camera:

public Camera Camera;
We utilized the camera variable in the constructor (const). The variable will need to be changed from camera to Camera there as well. To get our game to run successfully, we need to modify our game states that utilize the sprite batch to call Begin and End because we removed it from the main TunnelVision game class. Once those methods are modified, we can compile and run our game.

Creating the Game Logic

After we successfully compile and run our game, we can start working on our game logic. Fortunately, we have the framework in place where we can easily put in our game code. We will first work on our game play by modifying the playing state code file (/GameStates/PlayingState.cs).

We need to remove SpriteFont from our member fields and add in the following fields:

private MissileManager missileManager;
private EnemyManager enemyManager;
private List<Level> Levels;
private int currentLevel;

private float totalCreatedEnemies;
public int TotalCollisions;

private BoundingSphere playerSphere;
We are going to manage our enemies and our missiles, so we have set up some variables for those. We also created a list to store our levels. Before we dig into our playing state any further, we can build out these classes. The code for our Level class, which can be added as Level.cs to our main game folder, is as follows:
public class Level
{
   public int Missiles;
   public int Enemies;
   public int Time;
   public float EnemySpeed;
   
   public Level(int missiles, int enemies, int time, float enemySpeed)
   {
      Missiles = missiles;
      Enemies = enemies;
      Time = time + 1;
      EnemySpeed = enemySpeed;
   }
}
The class is pretty straightforward—we are providing a way to store the number of missiles that we are allowing on the screen at one time. One of the challenges of the game is to not expend too many bullets at once. The next challenge is how many enemies this level needs to generate before the level is over. We also have a timer that we will be using to award bonus points if all the enemies are killed before it runs out. Finally, we store the speed at which the enemy is moving in this level.

The enemy and missile managers do as their names imply and manage the missiles and the enemies, respectively. These objects share a lot of the same properties. Therefore, we are going to introduce a SceneObject and then have a PhysicalObject that inherits from our SceneObject. Although everything in this game will really be a physical object, we could have a trigger that, if reached, kicks off some animation; this would need to be a scene object, not a physical object. You’ll see that this makes more sense as we look at the code, starting with the SceneObject:

public abstract class SceneObject
{
   public Matrix World;
   public BoundingSphere BoundingSphere;
}
This is a very simplistic abstract class that stores our world matrix for the object as well as a bounding sphere. This allows us to place the object in the world and assign it a sphere so that if something collides with it, we could kick off an event such as spawning enemies, opening doors, doing a cut scene, or anything else we wanted. In our game, we will not be using an object that is purely a scene object, but it is good to have it here for future projects. The Microsoft.Xna.Framework namespace will need to be included because this class uses BoundingSphere. Our physical object inherits from this and has more properties for an actual drawable object. Here is the code for the PhysicalObject class:
public abstract class PhysicalObject : SceneObject
{
   public Vector3 Position;
   public Vector3 Velocity;
   public Vector3 Acceleration;
   public float Mass;
   public float Scale = 1.0f;
   public float Radius = 1.0f;
   public Color Color;
   public Matrix Rotation = Matrix.Identity;
   
   public virtual void Move(float elapsed)
   {
      //adjust velocity with our acceleration
      Velocity += Acceleration;
      
      //adjust position with our velocity
      Position += elapsed * Velocity;
      
      World = Matrix.CreateScale(Scale) * Rotation * Matrix.CreateTranslation(Position);
      
      BoundingSphere = new BoundingSphere(Position, Radius);
   }
}
We inherit from SceneObject so we get the bounding sphere and the world matrix, but we also add position, velocity, acceleration, mass, scale, radius, color, and rotation. This allows us to assign physical properties to our objects. We have a Move method that applies the physical forces we talked about in Chapter 16, “Physics Basics.” This method adds the acceleration to our velocity and adds our velocity (taking the time delta into account) to our position. It then uses the scale, rotation, and position to update the world matrix for the object. Finally, it recalculates the bounding sphere based on the position and radius of the object. We need to add the following using statements:
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
Now we are ready to look at our EnemyManager and MissileManager, which manage objects that inherit from our PhysicalObject. We will start with our MissileManager and our Missile class in particular:
class Missile : PhysicalObject
{
   public DateTime StartTime;
   public bool IsActive;
}
The missile object inherits all the properties from our physical object class and also includes the start time and a flag that states whether the missile is active. We will be treating this much like we did our particle system. We can jump right into our MissileManager to see how to manage these objects. Our MissileManager object will inherit from the DrawableGameComponent. As usual, we can start with our member fields and constructor:
public const int MISSILE_LIFE = 5; //5 seconds
private Model missile;
private Effect effect;

public Matrix View;
public Matrix Projection;

private Missile[] missiles;
private Texture2D missileTexture;

private int lastMissileIndex;
private float timer = 0;

public MissileManager(Game game) : base(game) { }
We have a constant that determines how long our missile should stay on the screen if it does not hit any enemies. One of the challenges we are presenting gamers is that only a certain number of missiles can be on the screen at any given time for each level. The more accurate the players are, the more frequently they can fire a missile. If they miss, the missile is active for 5 seconds, and if they have fired their allotment, players need to wait until the 5 seconds are up before they can fire another missile.

We also have fields that store the missile model and the effect we will be using for that model. We set our View and Projection properties (which our effect needs) inside of the game, so we set those fields with the public modifier. We have an array where we store and manage our missile objects and the texture we apply to those objects. Much like we did for our particle system, we are going to keep track of the last index in our array to which we added a missile so we know where to start when a new request is created to add a missile to our list.

Our constructor is empty and only passes the game object to the base DrawableGameComponent class. Next, we can look at the Load method that our game will call every time we start a new level. Each time a new level is started, we reset our array of missiles to handle the maximum amount the level allows the player to fire at one time. The code for the Load method is as follows:

public void Load(int capacity)
{
   missiles = new Missile[capacity];
   lastMissileIndex = 0;
   
   for (int i = 0; i < missiles.Length; i++)
   missiles[i] = new Missile();
}
At the beginning of the level, we reallocate our array and reset our last missile index. Finally, we loop through all the missiles and store an instance of each. Our LoadContent and UnloadContent methods are next:
protected override void LoadContent()
{
   missileTexture = Game.Content.Load<Texture2D>(@”Textures\FireGrade”);
   missile = Game.Content.Load<Model>(@”Models\sphere”);
   effect = Game.Content.Load<Effect>(@”Effects\VertexDisplacement”);
   effect.Parameters[“ColorMap”].SetValue(missileTexture);
   effect.Parameters[“AmbientColor”].SetValue(0.8f);
   
   base.LoadContent();
}

protected override void UnloadContent()
{
   missiles = null;
   
   base.UnloadContent();
}
In our LoadContent method, we retrieve the texture from our TextureManager and the effect from our EffectManager. We then set the parameters on the effect. Our UnloadContent method sets our missiles array to null.

We need to add the FireGrade texture to our Content project. We also need to add the sphere model to our project. Both of these assets can be found on the accompanying CD in this chapter’s code folder.

When the player fires a missile, we need to tell our MissileManager to add a missile to its array. Therefore, we provide a public method called AddMissile:

public bool AddMissile(Vector3 position, Vector3 direction, DateTime startTime)
{
   int index = lastMissileIndex;
   for (int i = 0; i < missiles.Length; i++)
   {
      if (!missiles[index].IsActive)
         break;
      else
      {
         index++;
         if (index >= missiles.Length)
            index = 0;
      }
      
      if (index == lastMissileIndex)
         return (false);
   }
   
   //at this point index is the one we want ...
   InitializeMissile(index, position, direction, startTime);
   
   missiles[index].IsActive = true;
   
   lastMissileIndex = index;
   
   return (true);
}
The AddMissile method loops through the entire array, finding the first empty slot it can initialize a missile into. If there are no free slots, the user has fired all the missiles allowed and the method returns false. The details of the InitializeMissile method are as follows:
private void InitializeMissile(int index, Vector3 position, Vector3 direction, DateTime startTime)
{
   missiles[index] = new Missile();
   missiles[index].Position = position;
   missiles[index].Acceleration = direction * 10f;
   missiles[index].Velocity = Vector3.Zero;
   missiles[index].StartTime = startTime;
}
The InitializeMissile method sets the values passed in from AddMissile, which gets the values from the game. Our MissileManager class also has a collision detection method. CheckCollision is used to determine if any of the missiles have collided with the bounding sphere passed in:
public bool CheckCollision(BoundingSphere check)
{
   for (int i = 0; i < missiles.Length; i++)
   {
      if ((missiles[i].IsActive) && (missiles[i].BoundingSphere.Intersects(check)))
      {
         RemoveMissile(i);
         return (true);
      }
   }
   
   return (false);
}
If a collision is detected, we remove the missile from the list by setting its active flag to false. This is done in the RemoveMissile method:
private void RemoveMissile(int index)
{
   missiles[index].IsActive = false;
}
This leaves just the Update and Draw methods. The Update method simply loops through all the active missiles and checks to see how long they have lived. If they have lived too long, they are removed from the array. If they are still alive, they are moved by calling the Move method on the Missile object, which ultimately calls the Move method on the PhysicalObject class:
public override void Update(GameTime gameTime)
{
   float elapsed = (float)gameTime.ElapsedGameTime.TotalSeconds;
   
   timer += 0.655f;
   
   for (int mi = 0; mi < missiles.Length; mi++)
   {
      //we do not want to update any inactive missiles
      if (!missiles[mi].IsActive)
         continue;
      
      if ((DateTime.Now - missiles[mi].StartTime) > TimeSpan.FromSeconds(MissileManager.MISSILE_LIFE))
         RemoveMissile(mi);
      else
         missiles[mi].Move(elapsed);
   }
   
   base.Update(gameTime);
}
The Update method also adds a fixed amount to a timer, which is used for the effect of the missile. The Draw method is shown here:
public override void Draw(GameTime gameTime)
{
   GraphicsDevice.RenderState.DepthBufferEnable = true;
   GraphicsDevice.RenderState.AlphaBlendEnable = true;
   
   effect.Parameters[“Timer”].SetValue(timer);
   
   effect.Parameters[“View”].SetValue(View);
   effect.Parameters[“Projection”].SetValue(Projection);
   
   for (int mi = 0; mi < missiles.Length; mi++)
   {
      if (!missiles[mi].IsActive)
         continue;
      
      effect.Parameters[“World”].SetValue(missiles[mi].World);
      
      Matrix[] transforms = new Matrix[missile.Bones.Count];
      missile.CopyAbsoluteBoneTransformsTo(transforms);
      
      foreach (ModelMesh mesh in missile.Meshes)
      {
         for (int i = 0; i < mesh.MeshParts.Count; i++)
         {
            // Set this MeshParts effect to our RedWire effect
            mesh.MeshParts[i].Effect = effect;
         }
      
         mesh.Draw();
      
         missiles[mi].BoundingSphere = mesh.BoundingSphere;
         missiles[mi].BoundingSphere.Center += missiles[mi].World.Translation;
      }
   }
   
   base.Draw(gameTime);
}
The Draw method makes sure the render state is in the right state before drawing anything. It sets the appropriate effect parameters; then it loops through all the missiles and applies the effect to them. It also recalculates the bounding sphere of the missile.

Before we get back to the PlayingState, we need to add the enemy manager. The concept is the same as our missile manager: Only a certain number of enemies can be displayed at one time, determined by each level. The enemy object has tracking AI built in to it as well. We start by creating the Enemy class, which inherits from PhysicalObject:

public class Enemy : PhysicalObject
{
   Random random = new Random(DateTime.Now.Millisecond);
   
   public Vector3 Target = Vector3.Zero;
   public Texture2D Texture;
   private float moveSpeed;
   private Vector3 Up, Forward;
   
   public Enemy(Texture2D texture, float moveSpeed)
   {
      Texture = texture;
      this.moveSpeed = moveSpeed;
      Scale = 0.01f;
      Radius = 5f;
      Position = XELibrary.Utility.GetRandomVector3(
         new Vector3(-300, -100, -100), new Vector3(300, 100, -100));
      
      Up = Vector3.Up;
      Forward = Vector3.Forward;
   }
   
   public override void Move(float elapsed)
   {
      Vector3 tv = Target - Position;
      tv.Normalize();
      
      Velocity = tv * moveSpeed;
      
      Forward = tv;
      
      Vector3 Right = Vector3.Normalize(Vector3.Cross(Forward, Vector3.Up));
      
      Up = Vector3.Normalize(Vector3.Cross(Right, Forward));
      
      Rotation = Matrix.Identity;
      Rotation.Forward = Forward;
      Rotation.Up = Up;
      Rotation.Right = Right;
      
      base.Move(elapsed);
   }
}
moveSpeed is the speed at which the enemies are moving toward the player. The Up and Forward vectors are stored so we can calculate the rotation. The position in which an enemy is generated is random. The Move method handles the AI for tracking the player as well as setting the rotation so the enemy is always facing the player. We define two vectors: Up and Forward. Then we calculate our Right vector by normalizing the cross-product of the Forward and Up vectors. Then we recalculate the Up vector again to make sure the vectors are truly perpendicular. Once we have all three vectors perfectly perpendicular to each other, we set our rotation matrix’s vectors so we can use it inside our base class. After setting all the properties for our enemy, the base class is called to finish the move process.

Our EnemyManager class inherits from the DrawableGameComponent class, just like our MissileManager class. The member fields we need for this class are as follows:

public const int MAX_ENEMIES = 10;
private Texture2D[] enemyTextures;
private Model enemy;
private Effect effect;
private Random rand = new Random();

public Matrix View;
public Matrix Projection;

public List<Enemy> Enemies = new List<Enemy>(MAX_ENEMIES);
In our constructor, we need to store a reference to our game object:
public EnemyManager(Game game) : base(game) { }
Microsoft.Xna.Framework, Microsoft.Xna.Framework.Graphics, and System.Collections.Generic will need to be added to the EnemyManager.cs file. The LoadContent method of EnemyManager is as follows:
protected override void LoadContent()
{
   enemyTextures = new Texture2D[3];
   enemyTextures[0] = Game.Content.Load<Texture2D>(@”Textures\wedge_p2_diff_v1”);
   enemyTextures[1] = Game.Content.Load<Texture2D>(@”Textures\wedge_p2_diff_v2”);
   enemyTextures[2] = Game.Content.Load<Texture2D>(@”Textures\wedge_p2_diff_v3”);
   enemy = Game.Content.Load<Model>(@”Models\p2_wedge”);
   effect = Game.Content.Load<Effect>(
     @”Effects\AmbientTexture”);
   
   effect.Parameters[“AmbientColor”].SetValue(.8f);
   
   base.LoadContent();
}
We are loading three different textures that the model we are loading can use. This model and these textures are taken from the Spacewar starter kit. They can also be found on the accompanying CD in this chapter’s code folder. The assets should be added to the appropriate folders in which the code is expecting to find them.

The UnloadContent method, which gets called by the XNA Framework whenever the graphics device is reset or the game exits, simply clears out the list of enemies:

protected override void UnloadContent()
{
   Enemies.Clear();
   Enemies = null;
   
   base.UnloadContent();
}
The next method in our enemy manager class is the Draw method:
public override void Draw(GameTime gameTime)
{
   GraphicsDevice.RenderState.AlphaTestEnable = false;
   GraphicsDevice.RenderState.AlphaBlendEnable = false;
   GraphicsDevice.RenderState.PointSpriteEnable = false;
   GraphicsDevice.RenderState.DepthBufferWriteEnable = true;
   GraphicsDevice.RenderState.DepthBufferEnable = true;
   
   effect.Parameters[“View”].SetValue(View);
   effect.Parameters[“Projection”].SetValue(Projection);
   
   for (int ei = 0; ei < Enemies.Count; ei++)
   {
      effect.Parameters[“World”].SetValue(Enemies[ei].World);
      effect.Parameters[“ColorMap”].SetValue(Enemies[ei].Texture);
      
      Matrix[] transforms = new Matrix[enemy.Bones.Count];
      enemy.CopyAbsoluteBoneTransformsTo(transforms);
      
      foreach (ModelMesh mesh in enemy.Meshes)
      {
         foreach (ModelMeshPart mp in mesh.MeshParts)
         {
            mp.Effect = effect;
         }
         
         mesh.Draw();
      }
   }
   
   base.Draw(gameTime);
}
First, we set our render state properties to the values we require. Then we set our effect’s view and projection properties. Finally, we loop through all our enemies and draw each one with the appropriate texture and world matrix being passed into the effect.

The last method we need to add in our EnemyManager class is the AddEnemy method:

public void AddEnemy(float moveSpeed)
{
   Enemies.Add(new Enemy(enemyTextures[rand.Next(0, 3)], moveSpeed));
}
This is a public method the playing state code will use to tell the manager to add another enemy to the list. The enemy will be assigned one of the three textures we have specified, along with its movement speed.

Creating the Game Logic (Con't)

Let’s return to the playing state code and add the following to the constructor:

playerSphere = new BoundingSphere(OurGame.Camera.Position, 1.5f);

missileManager = new MissileManager(Game);
Game.Components.Add(missileManager);
missileManager.Enabled = false;
missileManager.Visible = false;

enemyManager = new EnemyManager(Game);
Game.Components.Add(enemyManager);
enemyManager.Enabled = false;
enemyManager.Visible = false;

Levels = new List<Level>(10);
Levels.Add(new Level(50, 10, 60, 9.0f));
Levels.Add(new Level(25, 10, 60, 9.0f));
Levels.Add(new Level(15, 15, 60, 9.0f));
Levels.Add(new Level(10, 15, 60, 9.0f));
Levels.Add(new Level(5, 15, 60, 9.0f));
Levels.Add(new Level(5, 20, 60, 9.0f));
Levels.Add(new Level(5, 25, 60, 9.0f));
Levels.Add(new Level(5, 30, 60, 10.0f));
Levels.Add(new Level(5, 40, 90, 10.0f));
Levels.Add(new Level(3, 50, 90, 10.0f));

currentLevel = 0;
enemyManager.Enemies = new List<Enemy>(Levels[CurrentLevel].Enemies);
In this game, we are using a stationary camera, so we are not going to continually update the player’s bounding sphere. Instead, it is set once in the constructor. We add the missile manager and enemy manager game components and create all our levels.

Our PlayingState will start the game. The exposed method calls two private methods to prepare for the game and then to start the level. These methods are shown here:

public void StartGame()
{
   SetupGame();
   StartLevel();
}

private void SetupGame()
{
   TotalCollisions = 0;
   currentLevel = 0;
}

public void StartLevel()
{
   GamePad.SetVibration(0, 0, 0);
   enemyManager.Enemies.Clear();
   totalCreatedEnemies = 0;
   
   missileManager.Load(Levels[CurrentLevel].Missiles);
   
   GameManager.PushState(OurGame.StartLevelState.Value);
}
The only thing to point out in this code is that we push the StartLevelState onto the stack inside the StartLevel method.

Next, we can look at the Update method in our PlayingGameState. We can replace the contents of the existing Update method with the following:

float elapsed = (float)gameTime.ElapsedGameTime.TotalSeconds;

if (Input.WasPressed(0, Buttons.Back, Keys.Escape))
   GameManager.PushState(OurGame.StartMenuState.Value);

if (Input.WasPressed(0, Buttons.Start, Keys.Enter))
{
   // push our paused state onto the stack
   GameManager.PushState(OurGame.PausedState.Value);
}

if ((Input.WasPressed(0, Buttons.A, Keys.Space)) ||
   (Input.WasPressed(0, Buttons.RightShoulder, Keys.LeftControl)) ||
   Input.GamePads[0].Triggers.Right > 0)
{
   if (missileManager.AddMissile(new Vector3(
      OurGame.Camera.Position.X,
      OurGame.Camera.Position.Y - 1,
      OurGame.Camera.Position.Z + 1
      ), OurGame.Camera.Target - OurGame.Camera.Position,
      DateTime.Now))
   {
     //play sound
   }
}

if (enemyManager.Enabled)
{
   UpdateEnemies(elapsed);
   
   while (CheckCollisions())
   {
      //increase score if enemy was hit
   }
   
   //Are we finished with this level?
   if (TotalCollisions == Levels[CurrentLevel].Enemies)
   {
      TotalCollisions = 0;
      currentLevel++;
      
      //Are we finished with the game?
      if (CurrentLevel == Levels.Count)
      {
         //You won the game!!!
         GameManager.PushState(OurGame.WonGameState.Value);
         currentLevel--; //reset count back
      }
      else
      {
         StartLevel();
      }
   }
}

base.Update(gameTime);
We check our input and push on the start menu state or the paused state if it is appropriate. We check to see if the player has fired a missile and have a placeholder for playing a sound.

We update all the enemies that are on the screen and then check to see if any of the missiles have collided with them. We will review the CheckCollisions method next. If a collision did occur, we have a placeholder to increase the score. Now we check to see if any more enemies are left. If there aren’t, we check to see if any more levels are left. If all the levels have been finished, the game is won. Otherwise, the game moves on to the next level. The CheckCollisions method is as follows:

private bool CheckCollisions()
{
   for (int ei = 0; ei < enemyManager.Enemies.Count; ei++)
   {
      //See if an enemy is too close first
      if (enemyManager.Enemies[ei].BoundingSphere.Intersects(playerSphere))
      {
         GameManager.PushState(OurGame.LostGameState.Value);
         return (false);
      }
      
      //if not, then we can check our missiles
      if (missileManager.CheckCollision(enemyManager.Enemies[ei].BoundingSphere))
      {
         enemyManager.Enemies.RemoveAt(ei);
      
         TotalCollisions++;
      
         return (true);
      }
   }
   
   return (false);
}
First, we check to see if an enemy has collided with the camera. If that happens, the game is over. Otherwise, we check to see if any of the missiles have collided with the enemies. Our Update method also calls the UpdateEnemies method, which is shown here:
private void UpdateEnemies(float elapsed)
{
   if (totalCreatedEnemies < Levels[CurrentLevel].Enemies)
   {
      if (enemyManager.Enemies.Count < EnemyManager.MAX_ENEMIES)
      {
         enemyManager.AddEnemy(Levels[CurrentLevel].EnemySpeed);
         totalCreatedEnemies++;
      }
   }
   
   for (int ei = 0; ei < enemyManager.Enemies.Count; ei++)
   {
      enemyManager.Enemies[ei].Target = OurGame.Camera.Position;
      enemyManager.Enemies[ei].Move(elapsed);
   }
}
The UpdateEnemies method checks to see if there are still enemies to be generated. We only allow MAX_ENEMIES on the screen at one time, so if a level has more than that, we wait until an enemy is destroyed before another one is spawned. The method then loops through all the enemies and updates their target based on the camera’s position. For this game, this really isn’t needed because we have a stationary camera. We then move each enemy.

For now our Draw method is very lightweight—it is only setting the view and projection properties for the missile manager and the enemy manager. We can replace the current contents of the Draw method with the following:

missileManager.View = OurGame.Camera.View;
missileManager.Projection = OurGame.Camera.Projection;

enemyManager.View = OurGame.Camera.View;
enemyManager.Projection = OurGame.Camera.Projection;

base.Draw(gameTime
We need to clear out the contents of the LoadContent method. We will be adding code to that method a little later, but for now we just need to remove the old font we used in the previous demo.

We need to know which level we are on outside of our playing state, so we need to make a public property to expose it:

public int CurrentLevel
{
   get { return (currentLevel); }
}
We also need to modify our GameStateInterfaces code. Specifically, we need to modify the IPlayingState interface to include our StartGame method and this CurrentLevel property:
void StartGame();
int CurrentLevel { get; }
The final method we need to add to our PlayingState class is the StateChanged method. We override this method so we can turn on and off the appropriate game components:
protected override void StateChanged(object sender, EventArgs e)
{
   base.StateChanged(sender, e);
   
   if (GameManager.State != this.Value)
   {
      Visible = true;
      Enabled = false;
      missileManager.Enabled = false;
      missileManager.Visible = false;
      enemyManager.Enabled = false;
      enemyManager.Visible = false;
   }
   else
   {
      missileManager.Enabled = true;
      missileManager.Visible = true;
      enemyManager.Enabled = true;
      enemyManager.Visible = true;
   }
}
We specified an effect file for both the missile manager and the enemy manager. The enemy manager is using the AmbientTexture effect file from last chapter. The file needs to be added to our projects. The missile manager, however, is using a new effect file. The basis of the effect is the vertex deformation effect we created in Chapter 15, “Advanced HLSL.” Here’s the code for VertexDisplacement.fx:
float4x4 World : WORLD;
float4x4 View;
float4x4 Projection;

float4 AmbientColor : COLOR0;
float Timer : TIME;
float Offset = 1.0f;

texture ColorMap;
sampler ColorMapSampler = sampler_state
{
   texture = <ColorMap>;
   magfilter = LINEAR;
   minfilter = LINEAR;
   mipfilter = LINEAR;
   AddressU = Wrap;
   AddressV = Wrap;
};

struct VertexInput
{
   float4 Position : POSITION0;
   float2 TexCoord : TEXCOORD0;
};

struct VertexOutput
{
   float4 Position : POSITION0;
   float2 TexCoord : TEXCOORD0;
};

VertexOutput vertexShader(VertexInput input)
{
   VertexOutput output = (VertexOutput)0;
   float4x4 WorldViewProjection = mul(mul(World, View), Projection);
   output.TexCoord = input.TexCoord  + Timer * .005;
   
   float4 Pos = input.Position;
   float y = Pos.y * Offset + Timer;
   float x = sin(y) * Offset;
   Pos.x += x;
   
   output.Position = mul(Pos, WorldViewProjection);
   
   return( output );
}

struct PixelInput
{
   float2 TexCoord : TEXCOORD0;
};

float4 pixelShader(PixelInput input) : COLOR
{
   float4 color;
   color = tex2D(ColorMapSampler, input.TexCoord);
   return(color);
}

technique Default
{
   pass P0
   {
      VertexShader = compile vs_1_1 vertexShader();
      PixelShader = compile ps_1_4 pixelShader();
   }
}
This effect code is identical to the code we used in Chapter 15. The only difference is inside the vertex shader. Besides modifying the vertex position, we also modify the texture coordinates. When we shoot our missiles, they will wobble.

The next state we need to modify is our StartLevelState. We need to clear out the code inside the class and add the following private member fields:

private bool demoMode = true;
private bool displayedDemoDialog = false;

private DateTime levelLoadTime;
private readonly int loadSoundTime = 2500;

private string levelText = “LEVEL”;
private string currentLevel;
bool startingLevel = false;

private Vector2 levelTextPosition;
private Vector2 levelTextShadowPosition;
private Vector2 levelNumberPosition;
private Vector2 levelNumberShadowPosition;
We are going to play a sound as we are loading the level. We want the actual game play to start as soon as the sound is over. There is no way to get notified of a sound being completed, so we put in our own timer. We will add the sound later but put the code into place now to handle the timing. The start level state will also display the number of the level we are starting. We store the level text once so we can use it multiple times. We also set up two sets of vectors to hold the locations of the level text (that is, LEVEL) as well as the level number (that is, 1) and their drop-shadow locations.

The constructor did not change but is listed here for completeness:

public StartLevelState(Game game) : base(game)
{
   game.Services.AddService(typeof(IStartLevelState), this);
}
The updated StateChanged method allows us to start the logic when we enter our start level state:
protected override void StateChanged(object sender, EventArgs e)
{
   base.StateChanged(sender, e);
   
   if (GameManager.State == this.Value)
   {
      startingLevel = true;
      
      if (demoMode && !displayedDemoDialog)
      {
         //We could set properties on our YesNoDialog
         //so it could have a custom message and custom
         //Yes / No buttons ...
         //YesNoDialogState.YesCaption = “Of course!”;
         GameManager.PushState(OurGame.YesNoDialogState.Value);
         this.Visible = true;
         displayedDemoDialog = true;
         startingLevel = false;
      }
   }
   
   if (startingLevel)
   {
      //play sound
      
      levelLoadTime = DateTime.Now;
      
      currentLevel = (OurGame.PlayingState.CurrentLevel + 1).ToString();
      
      Vector2 viewport = new Vector2(GraphicsDevice.Viewport.Width, GraphicsDevice.Viewport.Height);
      Vector2 levelTextLength = OurGame.Font.MeasureString(levelText);
      Vector2 levelNumberLength = OurGame.Font.MeasureString(currentLevel);
      levelTextShadowPosition = (viewport – levelTextLength * 3) / 2;
      levelNumberShadowPosition = (viewport – levelNumberLength * 3) / 2;
      levelNumberShadowPosition.Y += OurGame.Font.LineSpacing * 3;
      levelTextPosition.X = levelTextShadowPosition.X + 2;
      levelTextPosition.Y = levelTextShadowPosition.Y + 2;
      levelNumberPosition.X = levelNumberShadowPosition.X + 2;
      levelNumberPosition.Y = levelNumberShadowPosition.Y + 2;
   }
}
The first part of the method is the same as it was in our previous game state demo. However, we also set the startingLevel flag with the initial value of true. We modify the demo condition to set the startingLevel flag to false. Assuming we are really starting the level, which will occur the first time if we are not in demo mode (or after the dialog box is closed if we are in demo mode), we begin playing our starting level sound. We have put a placeholder in to play that sound for now. We also initialize our level load time and set our current level variable. Finally, we initialize the vectors that store the position of the text we want to display when the level starts. The text will be centered on the screen.

The Update method for this state is as follows:

public override void Update(GameTime gameTime)
{
   if (DateTime.Now > levelLoadTime + new TimeSpan(0, 0, 0, 0, loadSoundTime))
   {
      //stop sound
      
      // change state to playing
      GameManager.ChangeState(OurGame.PlayingState.Value);
   }
   
   base.Update(gameTime);
}
Inside the Update method, we change to the PlayingState and stop the sound if enough time has passed. For now, we just have a placeholder where we will eventually stop the sound. The last method in this state is the Draw method:
public override void Draw(GameTime gameTime)
{
   if (startingLevel)
   {
      OurGame.SpriteBatch.Begin();
      OurGame.SpriteBatch.DrawString(OurGame.Font, levelText,
         levelTextShadowPosition, Color.Yellow, 0, Vector2.Zero, 3.0f,
         SpriteEffects.None, 0);
      OurGame.SpriteBatch.DrawString(OurGame.Font, levelText,
         levelTextPosition, Color.Red, 0, Vector2.Zero, 3.0f,
         SpriteEffects.None, 0);
      OurGame.SpriteBatch.DrawString(OurGame.Font, currentLevel,
         levelNumberShadowPosition, Color.Yellow, 0, Vector2.Zero, 3.0f,
         SpriteEffects.None, 0);
      OurGame.SpriteBatch.DrawString(OurGame.Font, currentLevel,
         levelNumberPosition, Color.Red, 0, Vector2.Zero, 3.0f,
         SpriteEffects.None, 0);
      OurGame.SpriteBatch.End();
   }
   
   base.Draw(gameTime);
}
The Draw method simply draws the level text and the level number in the right position, complete with a drop-shadow effect. It only draws this text if we are actually starting the level. If the YesNoDialog (Demo mode) is on the stack, we do not want to display the text.

We need to declare a font variable in our TunnelVision game class:

public SpriteFont Font;
Inside the LoadContent method we need to load our font:
Font = Content.Load<SpriteFont>(@”Fonts\Arial”);
Before we compile and run our changes, we need to modify the StartMenuState class. Inside the Update method, we need to replace the contents of the condition where we check if either Start or Enter was pressed with the following code:
if (GameManager.ContainsState(OurGame.PlayingState.Value))
   GameManager.PopState();
else
{
   //starting game, queue first level
   GameManager.ChangeState(OurGame.PlayingState.Value);
   OurGame.PlayingState.StartGame();
}
We are still popping off our start menu state if our stack contains a playing state. However, instead of changing the state to the StartLevelState like in the previous demo, we are changing the state to PlayingState and calling its StartGame method.

At this point we can compile and run the game. The game logic is in place, but it is rather rough around the edges.

Creating the Crosshair

To allow better aiming, we need to add a crosshair to our screen. To start, we need to add the following texture to our member field list of the PlayingState class:
private Texture2D crossHair;
Inside our Draw method we need to add the following code:
OurGame.SpriteBatch.Begin();
if (OurGame.DisplayCrosshair)
{
   OurGame.SpriteBatch.Draw(crossHair, new Rectangle(
     (GraphicsDevice.Viewport.Width - crossHair.Width) / 2,
     (GraphicsDevice.Viewport.Height - crossHair.Height) / 2,
     crossHair.Width, crossHair.Height), Color.White);
}
OurGame.SpriteBatch.End();
We actually populate the crosshair texture inside our LoadContent method:
protected override void LoadContent()
{
   crossHair = Content.Load<Texture2D>(@”Textures\crosshair”);
   base.LoadContent();
}
The texture can be found in the usual place. Later, we are going to provide an option to turn on and off the crosshair, so we need to create the public Boolean DisplayCrosshair field in our TunnelVision game code. It should be initialized to true. Now, we can more easily see where we are aiming!

Creating the Game-Specific Camera

Now we are going to add a new camera directly to our game. We are not going to add this to the XELibrary because it is a special camera that most likely will not be reused. The purpose of this new camera is to handle input a little differently and to restrict movement. The code for the new TunnelVisionCamera.cs file is as follows:
using System;
using System.Collections.Generic;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Input;
using XELibrary;

namespace TunnelVision
{
   public partial class TunnelVisionCamera : Camera
   {
   private float spinLeft = 0;
   private float spinRight = 0;
   private float spinDown = 0;
   private float spinUp = 0;
   
   private float spinLeftChange = 0;
   private float spinRightChange = 0;
   private float spinDownChange = 0;
   private float spinUpChange = 0;
   
   public TunnelVisionCamera(Game game) : base(game) {}
   
   public override void Update(GameTime gameTime)
   {
      if (!UpdateInput)
      return;
      
      float timeDelta = (float)gameTime.ElapsedGameTime.TotalSeconds;
      
      if (input.KeyboardState.IsKeyDown(Keys.Left))
         spinLeftChange += .1f;
      else
         spinLeftChange -= .1f;
      spinLeftChange = MathHelper.Clamp(spinLeftChange, 0, 1);
      spinLeft = spinLeftChange;
      if (input.GamePads[playerIndex].ThumbSticks.Left.X < 0)
         spinLeft = -input.GamePads[playerIndex].ThumbSticks.Left.X;
      if (spinLeft > 0)
         cameraYaw += (Utility.PowerCurve(spinLeft) * SpinRate * timeDelta);
      
      if (input.KeyboardState.IsKeyDown(Keys.Right))
         spinRightChange += .1f;
      else
         spinRightChange -= .1f;
      spinRightChange = MathHelper.Clamp(spinRightChange, 0, 1);
      spinRight = spinRightChange;
      if (input.GamePads[playerIndex].ThumbSticks.Left.X > 0)
         spinRight = input.GamePads[playerIndex].ThumbSticks.Left.X;
      if (spinRight > 0)
         cameraYaw -= (Utility.PowerCurve(spinRight) * SpinRate * timeDelta);
      
      if (input.KeyboardState.IsKeyDown(Keys.Down))
         spinDownChange += .1f;
      else
         spinDownChange -= .1f;
      spinDownChange = MathHelper.Clamp(spinDownChange, 0, 1);
      spinDown = spinDownChange;
      if (input.GamePads[playerIndex].ThumbSticks.Left.Y < 0)
         spinDown = -input.GamePads[playerIndex].ThumbSticks.Left.Y;
      if (spinDown > 0)
         cameraPitch -= (Utility.PowerCurve(spinDown) * SpinRate *
             timeDelta);
      
      if (input.KeyboardState.IsKeyDown(Keys.Up))
         spinUpChange += .1f;
      else
         spinUpChange -= .1f;
      spinUpChange = MathHelper.Clamp(spinUpChange, 0, 1);
      spinUp = spinUpChange;
      if (input.GamePads[playerIndex].ThumbSticks.Left.Y > 0)
         spinUp = input.GamePads[playerIndex].ThumbSticks.Left.Y;
      if (spinUp > 0)
         cameraPitch += (Utility.PowerCurve(spinUp) * SpinRate *
             timeDelta);
      
      //reset camera angle if needed
      if (cameraYaw > 80)
         cameraYaw = 80;
      else if (cameraYaw < -80)
         cameraYaw = -80;
      
      //keep camera from rotating a full 90 degrees in either direction
      if (cameraPitch > 89)
         cameraPitch = 89;
      if (cameraPitch < -89)
         cameraPitch = -89;
      
      Matrix rotationMatrix;
      Vector3 transformedReference;
      
      Matrix.CreateRotationY(MathHelper.ToRadians(cameraYaw),
         out rotationMatrix);
      
      //add in pitch to the rotation
      rotationMatrix = Matrix.CreateRotationX(
         MathHelper.ToRadians(cameraPitch)) * rotationMatrix;
      
      // Create a vector pointing the direction the camera is facing.
      Vector3.Transform(ref cameraReference, ref rotationMatrix,
         out transformedReference);
      // Calculate the position the camera is looking at.
      Vector3.Add(ref cameraPosition, ref transformedReference,
         out cameraTarget);
      
      Matrix.CreateLookAt(ref cameraPosition, ref cameraTarget,
         ref cameraUpVector, out view);
      }
   }
}
This is very similar to the base object’s Update method, except that we are restricting movement. We only allow the camera to move 80 degrees left or right. The pitch did not change. Instead of going through the entire class, line by line, we’ll just look at the section of code that handles if the user rotated to the left and infer how the rest of the movements work:
if (input.KeyboardState.IsKeyDown(Keys.Left))
   spinLeftChange += .1f;
else
   spinLeftChange -= .1f;
spinLeftChange = MathHelper.Clamp(spinLeftChange, 0, 1);
spinLeft = spinLeftChange;
if (input.GamePads[playerIndex].ThumbSticks.Left.X < 0)
   spinLeft = -input.GamePads[playerIndex].ThumbSticks.Left.X;
if (spinLeft > 0)
   cameraYaw += (Utility.PowerCurve(spinLeft) * SpinRate * timeDelta);
During game play, the keyboard movement was too fast and too jerky. To solve this, we build up our spin left value. In the base class, it simply gets set to 1. Here, we are adding 10% each frame and clamping the results to 1. Now we can tap the keyboard to have more precise control over targeting our enemies. If the game pad is used to rotate the camera, we use a new helper method to produce a curve in the movement. In the Utility.cs file in the XELibrary we can add the following code
Private const float power = 3;
public static float PowerCurve(float value)
{
   return ((float)Math.Pow(Math.Abs(value), power) * Math.Sign(value));
}
The PowerCurve helper method provides a curve we can apply to the values our thumbstick produces. Instead of strictly using the value of the thumbstick, we are making the low values lower, which gives us more control. Now as we barely move the thumbstick, the camera will barely move. And when we move the controller to target our enemies, we have more precise control.

We need to modify our Camera object in the XELibrary. The following public member field needs to be created:

public bool UpdateInput = true;
The following condition needs to be added at the very top of the Update method:
if (!UpdateInput)
{
   base.Update(gameTime);
   return;
}
We also want to change the modifier for the cameraReference, cameraTarget, cameraUpVector, view, cameraYaw, and cameraPitch fields to be protected instead of private. The private const spinRate and moveRate should be changed to public and no longer be a const because we need to be able to set them anywhere.

To use this new camera, we need to change our TunnelVision game class to use TunnelVisionCamera instead of Camera. We also need to modify our PlayingState class to use the new UpdateMethod Boolean property. Inside the StateChanged method we need to add the following code to the first branch of our condition:

OurGame.Camera.UpdateInput = false;
We need to do the opposite in the else of our if condition:
OurGame.Camera.UpdateInput = true;
Inside the constructor of PlayingState, we need to add the following code to set the move rate and spin rate of our camera:
OurGame.Camera.MoveRate = 10;
OurGame.Camera.SpinRate = 60;
We have improved our input handling and have restricted movement on our camera so we only rotate 80 degrees to the left or right instead of the full 360 degrees.

Summary

In this chapter, we have laid the foundation for creating our game. We have put into place all the game logic. There were not really any new concepts in this chapter, but by putting what you learned in previous chapters into practice, we should be well on our way to creating our own masterpieces. The next two chapters are spent updating our game states and UI enhancements.

Reproduced from the book Microsoft XNA Game Studio 3.0 Unleashed. Copyright? 2009. Reproduced by permission of Pearson Education, Inc., 800 East 96th Street, Indianapolis, IN 46240. Written permission from Pearson Education, Inc. is required for all other uses

Discuss this article in the forums


Date this article was posted to GameDev.net: 5/29/2009
(Note that this date does not necessarily correspond to the date the article was written)

See Also:
Programming

© 1999-2009 Gamedev.net. All rights reserved. Terms of Use Privacy Policy
Comments? Questions? Feedback? Click here!