Beginning Game Development: Part V - Adding Units
Welcome to the fifth article on beginning game development. At this point we have a working 3D environment and can manipulate the camera direction and location using thekeyboard and mouse. In this article we are going to add 3D objects to the game using predefined mesh files and implement some simple culling.
Code cleanup
The cleanup in this article consists mainly of fixing the navigation keys and removing some items we no longer need. The following changes have already been integrated into the code for this article:
- Replaced the radian/degree conversion methods in the Camera class with the utility classes in the Geometry class.
- Fixed the key assignments in the CheckForInput method in the GameEngineclass so that:
- W and S adjust the Z-axis. W is forward (positive) and S backward (negative)
- A and D adjust the X-axis. A is left (negative) and D is right (positive).
- Q and Z adjust the Y-axis. Q is up (positive) and Z is down (negative). Movement along the Y-axis is going to be removed later on, since our tankcan not fly.
- Adjusted the initial position of the camera to be above the surface level by setting the
_z
variable in the Camera class to 10. This represents our vantage point in the tank, which should be higher than zero. - Removed the Joystick class.
- Updated to the August SDK.
- Changed the GetElapsedTime method in the HiResTimer class to return a float and changed
_deltaTime
in GameEngine to a float. - Removed HiResTimer.Reset from the GameEngine Render method, movedHiResTimer.Start into the constructor of the GameEngine class.
IDispose
You may have noticed that some of the classes, such as the Keyboard and Mouseclasses, implement the IDisposable interface. This is an implementation of the Dispose pattern in .NET as explained in the .NET Framework Reference topic Implementing Finalize and Dispose to Clean Up Unmanaged Resources.
The Dispose pattern in .NET is intended to be used when a program makes use of resources that are not managed by the .NET runtime. These "unmanaged" resources need to be cleaned up in a special way to ensure that they are released in a deterministic manner. Since the .NET garbage collection is non-deterministic, we need to use a particular set of steps to ensure this cleanup is done correctly. These steps are defined in the Dispose pattern.
We use a lot of unmanaged resources in game development, so it is best to implement the Dispose pattern in every class that interacts with DirectX or file resources (almost all of our classes). This will protect us against memory leaks and increase the performance of the game.
You can read the topic mentioned above to get more background information on this pattern and garbage collection for .NET. I have added the Dispose pattern in all of the classes for this article, and will do so going forward.
What we need in BattleTank 2005 now are units. If you go back to the screenshot of the original game shown in the first article, you see that we need to add shapes and opposing tanks. The shapes are obstacles for you or the enemy tanks to use as cover. The enemy tanks are what we are going to eventually shoot at. These objects also aid us in navigation in the otherwise bare landscape. You may remember that we had to write out the camera location to the console to see that we were moving because there were no reference points in the scene. In the next article we will complete the scene by adding some terrain.
Units
For BattleTank 2005 we are going to have two types of 3D objects: Obstacles and Tanks. The main difference between them is that tanks can move and obstacles can not. Since we are going to have lots of obstacles and tanks, we are going to organize them so that they can be manipulated in bulk. We do this by adding them to a collection.
We could add both types of objects to a single collection, but it makes sense to separate each type into its own collection. Separating the objects in this manner allows us to concentrate on a particular group without incurring the overhead of testing each unit for its type, which improves performance. We can then update the position of the mobile units while skipping the stationary units.
Generics
In previous versions of .NET, creating a collection class to hold the units and ensuring that it was type-safe was pretty involved. Each collection in .NET 1.0 and 1.1 was a collection of objects. This meant that it accepted any value or reference type, making it very flexible. But this also meant that we had to cast the object to its proper type whenever we retrieved an object from the collection. This meant we ran the danger of the object cast failing. Accounting for the possible exception and testing each object before the cast incurred more overhead.
With .NET 2.0 we can use Generics to create type-safe collections for us with minimum effort. These collection classes are safer and perform better than regular collection. This is only one of the possible uses of generics, but probably the most common.
To learn more about generics check out these articles at MSDN:
All units, regardless of their purpose, have a number of things in common. To make the application easier to maintain, we are going to factor all of the common characteristics of the unit into a single base class. All units will derive from the common base class and be extended with custom properties and methods.
Creating a base class and derived classes creates an object hierarchy that then allows us to treat all units polymorphically, which is a very powerful concept in object oriented programming. Check out these Visual Studio .NET topics on polymorphism at MSDN:
- Visual Basic Reference: Polymorphism
- Visual Basic and Visual C# Concepts: Polymorphism in Components
The resulting base class looks like this.
Visual C#
public abstract class UnitBase : IDisposable { public UnitBase (Device device, string meshFile, Vector3 position, float scale ) public void Render ( Camera camera ) public bool IsCulled public Vector3 Position public float Radius private void LoadMesh ( ) private void ComputeRadius ( ) private Vector3 _position; private float _radius; private bool _isCulled; private Device _device; private string _meshFile; private float _scale; private Mesh _mesh = null; private Material[] _meshMaterials; private Texture[] _meshTextures; }
Visual Basic
Public MustInherit Class UnitBase Implements IDisposable Public Sub New(ByVal device As Device, _ ByVal meshFile As String, ByVal position As Vector3, _ ByVal scale As Single) Public Sub Render(ByVal camera As Camera) Public Property IsCulled() As Boolean Public Property Position() As Vector3 Public Property X() As Single Public Property Y() As Single Public Property Z() As Single Public ReadOnly Property Radius() As Single Public Sub Dispose() Implements IDisposable.Dispose Protected Overridable Sub Dispose(ByVal disposing As Boolean) Protected Overrides Sub Finalize() Private _disposed As Boolean Private Sub LoadMesh() Private Sub ComputeRadius() Private m_position As Vector3 Private m_radius As Single Private m_isCulled As Boolean Private m_device As Device Private m_meshFile As String Private m_scale As Single Private m_mesh As Mesh = Nothing Private m_meshMaterials As Material() Private m_meshTextures As Texture() End Class
While most of this should make sense, you may be wondering what a mesh file is and why we are loading it.
3D Modeling
At some point you realize that creating complex 3D objects in code line by line is pretty silly. The simple cube we created for the skybox alone was almost 200 lines of code. There has to be a better way to create 3D objects.
Most 3D models are created by artists using dedicated modeling programs such as Maya or 3ds Max. These programs store the information about their models in proprietary file formats (iff for Maya and 3ds for 3DS Max). DirectX can not directly read these file formats, but it can read a format called the X file.
X Files
DirectX defines a file format called the X file format. It contains the definition for a 3D model. We can use these files to dramatically reduce the amount of code we need to write when loading 3D models into our game by loading them from the X file. In DirectX lingo, the X files are mesh files.
Mesh
If you remember from our previous article, a mesh is data that describes a 3D shape. Mesh data includes a list of vertices that comprise the shape, information on how the vertices are connected to each other, and texture information for all vertices.
There are also conversion programs available that convert 3D objects files from other formats into the X file format. The DirectX SDK actually ships with plug-ins for Maya and 3ds Max that allow you to convert files created with these programs into the X file format.
So where can you get some X files to start working with? The DirectX SDK includes a folder called Media under the Samples folder which contains a number of X files that you can use. The DirectX SDK also includes a number of utilities that allow you to work with the X files such as the DirectX viewer and MeshViewer. All of the utilities are located in the Utilities folder. Check out these and the other utilities that come with the SDK, as they can save you time and effort.
Free 3D models: Unless you are very graphically talented it is probably best for an artist to create the models for your game. There are a large number of free 3D models available on the internet created by artists to show of their skills. Generally they don't mind if you use their work for you own gratification, but if you use it in a commercial game, that's a whole other issue. Make sure to read and understand the usage rules for each model before using it. A good site for free models is: http://www.3dcafe.com/.
Using X files with predefined models opens up an entirely new world of integrating 3D objects into our game. No longer do we need to worry abut the low-level details of each object, we can simply load a previously created mesh file.
Updating the Skybox
The first place we are going to use this newfound knowledge is to clean up the skybox code. The SDK includes a file called lobby_skybox.x in the Samples\Media\Lobby folder that describes a cube like the one we are currently using for our sky box. I copied this file to the Resources folder as skybox.x and updated the texture files to match the names of our texture files.
Changing textures: Most X files can be opened in a simple text editor. You can search the file for the references to the texture files (look for TextureFilename) and replace them with your own, or you can substitute your own texture files during the mesh loading phase by passing the texture file name to the TextureLoader.FromFile method. If you want to see something cool, copy the lobby_skybox.x files and all of the JPG files from the SDK media folder to the resource folder, then change the name of the X file in the skybox LoadMesh method to this X file. The resulting skybox is the lobby of the building where all the game developers work at Microsoft.
In the Skybox class remove the SetupCubeFaces method, the six methods starting with Copy (CopyLeftFaceVertexBuffer, CopyFrontFaceVertexBuffer, etc.) and theRenderFace method. You can also remove all of the private variables declared at the bottom of the class except for the Device variable. In the Render method, remove the lines of code that check the Pitch and Heading of the camera. Finally, replace the call toSetupCubeFaces in the constructor with a call to LoadMesh.
Now add the following three variable declarations at the bottom of the class.
Visual C#
private Mesh _mesh = null; private Material[] _meshMaterials; private Texture[] _meshTextures;
Visual Basic
Private m_mesh As Mesh = Nothing Private m_meshMaterials As Material() Private m_meshTextures As Texture()
We already know what a mesh and a texture is, but what is the material?
Material
Materials describe how polygons reflect ambient and diffuse light, as well as information about specular highlights and if the polygons appear to emit light. The main thing to remember is that while textures define how polygons look, materials define how they reflect light.
Loading a Mesh
Loading the mesh from a file is very simple. All you have to do is to call the FromFilemethod of the Mesh class. (You should spend some time to familiarize yourself with the Mesh class and its methods, since it is one of the core classes you will use.)
In the Skybox class add the following code.
Visual C#
private void LoadMesh ( ) { ExtendedMaterial[] materials = null; Directory.SetCurrentDirectory ( Application.StartupPath + @"\..\..\..\Resources\" ); _mesh = Mesh.FromFile (@"skybox.x", MeshFlags.SystemMemory, _device, out materials); if ( ( materials != null ) && ( materials.Length > 0 ) ) { _meshTextures = new Texture[materials.Length]; _meshMaterials = new Material[materials.Length]; for ( int i = 0 ; i < materials.Length ; i++ ) { _meshMaterials[i] = materials[i].Material3D; _meshMaterials[i].Ambient = _meshMaterials[i].Diffuse; if (materials[i].TextureFilename != null && (materials[i].TextureFilename != string.Empty)) _meshTextures[i] = TextureLoader.FromFile ( _device, materials[i].TextureFilename ); } } }
Visual Basic
Private Sub LoadMesh() Dim materials As ExtendedMaterial() = Nothing Directory.SetCurrentDirectory(Application.StartupPath _ & "\..\..\..\Resources\") m_mesh = Mesh.FromFile("skybox.x", MeshFlags.SystemMemory, _ m_device, materials) If (Not (materials Is Nothing)) AndAlso _ (materials.Length > 0) Then m_meshTextures = New Texture(materials.Length) {} m_meshMaterials = New Material(materials.Length) {} Dim i As Integer = 0 While i < materials.Length m_meshMaterials(i) = materials(i).Material3D m_meshMaterials(i).Ambient = m_meshMaterials(i).Diffuse If Not (materials(i).TextureFilename Is Nothing) AndAlso _ (Not (materials(i).TextureFilename = String.Empty)) Then m_meshTextures(i) = TextureLoader.FromFile( _ m_device, materials(i).TextureFilename) End If System.Math.Min(System.Threading.Interlocked.Increment(i), i - 1) End While End If End Sub
While DirectX handles most of the work of reading the X file and converting it into an object for us (i.e. the creation of the vertex and index buffers etc.) we have to manually manage loading the materials and textures for the mesh.
The last step to integrate the X file and textures into the Skybox is to modify the Rendermethod of the Skybox class. Immediately after the
_device.RenderState.CullMode = Microsoft.DirectX.Direct3D.Cull.None;
line add the following code.
Visual C#
for ( int i = 0 ; i < _meshMaterials.Length ; i++ ) { _device.Material = _meshMaterials[i]; _device.SetTexture ( 0, _meshTextures[i] ); _mesh.DrawSubset ( i ); }
Visual Basic
While i < m_meshMaterials.Length m_device.Material = m_meshMaterials(i) m_device.SetTexture(0, m_meshTextures(i)) m_mesh.DrawSubset(i) System.Math.Min(System.Threading.Interlocked.Increment(i), i - 1) End While
Once again we iterate over the meshMaterials and then call the DrawSubset method of the Mesh to draw each subset in turn. That's it; the entire Skybox class is now only about 80 lines long and much easier to read.
Returning to the UnitBase class, the next item we need to look at is the IsCulled flag.
Culling
We briefly covered culling in the third article. Culling is simply the removal of entire objects from the scene so they will not be rendered. The logic to determine what objects should be removed can range from the very simple to the very complex. In BattleTank 2005 we are going to cull all of the objects that do not fall into the view frustum of the scene.
To determine if the unit is in the view frustum, we enhance the Camera class to provide us the info about the current view frustum so that we can check each object and see if it falls inside or outside of the frustum. To perform this check, we use the Radius property of the UnitBase class which is computed in the ComputeRadius method.
The BoundingSphere method of the Geometry class (the same one we use now for converting radians to degrees and vice versa) computes a sphere that completely contains all the points in the mesh using a vertex data of the mesh. The mesh contains this information in the vertex buffer. To access this buffer, it is best to lock it before access and unlock it when done. You should also make sure to dispose the vertex buffer when you are done. The safest way to do this is to leverage the
using
statement that will ensure that Dispose is called regardless of what happens.
Visual C#
private void ComputeRadius ( ) { using ( VertexBuffer vertexBuffer = _mesh.VertexBuffer ) { GraphicsStream gStream = vertexBuffer.Lock ( 0, 0, LockFlags.None ); Vector3 tempCenter; _radius = Geometry.ComputeBoundingSphere (gStream, _mesh.NumberVertices, _mesh.VertexFormat, out tempCenter ) * _scale; vertexBuffer.Unlock ( ); } }
Visual Basic
Private Sub ComputeRadius() Dim vertexBuffer As VertexBuffer = Nothing Try vertexBuffer = m_mesh.VertexBuffer Dim gStream As GraphicsStream = _ vertexBuffer.Lock(0, 0, LockFlags.None) Dim tempCenter As Vector3 m_radius = Geometry.ComputeBoundingSphere(gStream, _ m_mesh.NumberVertices, m_mesh.VertexFormat, tempCenter) * _ m_scale Finally vertexBuffer.Unlock() vertexBuffer.Dispose() End Try End Sub
You can probably understand why using the radius of the object is a very rough way of culling objects. It works fine and is fairly accurate if the objects are simple shapes, but for more complex shapes the bounding sphere becomes much larger than the basic object itself.
In a commercial game a lot of effort is invested in creating a culling routine that eliminates the most objects it can. We could also get fancy here and identify objects that are only partially in the frustum, but without the corresponding ability to render only a portion of the unit, we are just wasting our time. Instead we will treat any unit that has even a single point in the view frustum as being completely in the frustum.
So now that we have the radius of the unit, we need to know what the frustum is so we can check the radius against it.
View Frustum
Adding the frustum info to the camera is easy. First we add some data structures to hold the information about the view frustum. If you recall from the previous article, a frustum resembles a pyramid with the top cut off. This means that we need to store the corners of the top and bottom square and the plane for each side. For the corners we use an array of the familiar Vector3 structure. For the planes DirectX provides a convenient Planestructure.
Visual C#
private Vector3[] _frustumCorners; private Plane[] _frustumPlanes;
Visual Basic
private Vector3[] _frustumCorners; private Plane[] _frustumPlanes;
We then initialize the arrays in the constructor (two squares with four corners equals 8 points, and the four sides of the polygon plus the top and bottom make six planes).
Visual C#
_frustumCorners = new Vector3[8]; _frustumPlanes = new Plane[6];
Visual Basic
m_frustumCorners = New Vector3(8) {} m_frustumPlanes = New Plane(6) {}
The next step is to compute the frustum using the current view and projection matrices.
Visual C#
private void ComputeViewFrustum ( ) { Matrix matrix = _viewMatrix * _perspectiveMatrix; matrix.Invert ( ); _frustumCorners[0] = new Vector3 ( -1.0f, -1.0f, 0.0f ); // xyz _frustumCorners[1] = new Vector3 ( 1.0f, -1.0f, 0.0f ); // Xyz _frustumCorners[2] = new Vector3 ( -1.0f, 1.0f, 0.0f ); // xYz _frustumCorners[3] = new Vector3 ( 1.0f, 1.0f, 0.0f ); // XYz _frustumCorners[4] = new Vector3 ( -1.0f, -1.0f, 1.0f ); // xyZ _frustumCorners[5] = new Vector3 ( 1.0f, -1.0f, 1.0f ); // XyZ _frustumCorners[6] = new Vector3 ( -1.0f, 1.0f, 1.0f ); // xYZ _frustumCorners[7] = new Vector3 ( 1.0f, 1.0f, 1.0f ); // XYZ for ( int i = 0 ; i < _frustumCorners.Length ; i++ ) _frustumCorners[i] = Vector3.TransformCoordinate ( _frustumCorners[i], matrix ); // Now calculate the planes _frustumPlanes[0] = Plane.FromPoints ( _frustumCorners[0], _frustumCorners[1], _frustumCorners[2] ); // Near _frustumPlanes[1] = Plane.FromPoints ( _frustumCorners[6], _frustumCorners[7], _frustumCorners[5] ); // Far _frustumPlanes[2] = Plane.FromPoints ( _frustumCorners[2], _frustumCorners[6], _frustumCorners[4] ); // Left _frustumPlanes[3] = Plane.FromPoints ( _frustumCorners[7], _frustumCorners[3], _frustumCorners[5] ); // Right _frustumPlanes[4] = Plane.FromPoints ( _frustumCorners[2], _frustumCorners[3], _frustumCorners[6] ); // Top _frustumPlanes[5] = Plane.FromPoints ( _frustumCorners[1], _frustumCorners[0], _frustumCorners[4] ); // Bottom }
Visual Basic
Private Sub ComputeViewFrustum() Dim matrix As Matrix = m_viewMatrix * m_perspectiveMatrix matrix.Invert() m_frustumCorners(0) = New Vector3(-1.0F, -1.0F, 0.0F) m_frustumCorners(1) = New Vector3(1.0F, -1.0F, 0.0F) m_frustumCorners(2) = New Vector3(-1.0F, 1.0F, 0.0F) m_frustumCorners(3) = New Vector3(1.0F, 1.0F, 0.0F) m_frustumCorners(4) = New Vector3(-1.0F, -1.0F, 1.0F) m_frustumCorners(5) = New Vector3(1.0F, -1.0F, 1.0F) m_frustumCorners(6) = New Vector3(-1.0F, 1.0F, 1.0F) m_frustumCorners(7) = New Vector3(1.0F, 1.0F, 1.0F) Dim i As Integer = 0 While i < m_frustumCorners.Length m_frustumCorners(i) = _ Vector3.TransformCoordinate(m_frustumCorners(i), matrix) System.Math.Min( _ System.Threading.Interlocked.Increment(i), i - 1) End While m_frustumPlanes(0) = Plane.FromPoints(m_frustumCorners(0), m_frustumCorners(1), m_frustumCorners(2)) m_frustumPlanes(1) = Plane.FromPoints( _ m_frustumCorners(6), _ m_frustumCorners(7), _ m_frustumCorners(5)) m_frustumPlanes(2) = Plane.FromPoints( _ m_frustumCorners(2), _ m_frustumCorners(6), _ m_frustumCorners(4)) m_frustumPlanes(3) = Plane.FromPoints( _ m_frustumCorners(7), _ m_frustumCorners(3), _ m_frustumCorners(5)) m_frustumPlanes(4) = Plane.FromPoints( _ m_frustumCorners(2), _ m_frustumCorners(3), _ m_frustumCorners(6)) m_frustumPlanes(5) = Plane.FromPoints( _ m_frustumCorners(1), _ m_frustumCorners(0), _ m_frustumCorners(4)) End Sub
First we combine the view and projection matrices by multiplying them. Next we initialize the eight corners of the frustum as a cube immediately in front of the camera. These corners are then transformed and used to create the 6 planes using the FromPointsmethod of the plane.
The frustum is computed upon initialization of the class and on each render loop. Add a call to ComputeViewFrustum( ) to the bottom of the constructor of the Camera class and at the end of the Render method of the Camera class. (It needs to be at the end so it can use the newly computed view and projection matrices.) Now we can use the computed frustum and the radius for each unit to determine whether any part of the unit is in the frustum and should be rendered. The IsInViewFrustum method returns true if the unit is inside the frustum and false otherwise.
Visual C#
public bool IsInViewFrustum ( UnitBase unitToCheck ) { foreach ( Plane plane in _frustumPlanes ) { if ( plane.A * unitToCheck.Position.X + plane.B * unitToCheck.Position.Y + plane.C * unitToCheck.Position.Z + plane.D <= ( -unitToCheck.Radius ) ) return false; } return true; }
Visual Basic
Public Function IsInViewFrustum(ByVal unitToCheck As UnitBase) As Boolean For Each plane As Plane In m_frustumPlanes If plane.A * unitToCheck.Position.X + plane.B * _ unitToCheck.Position.Y + plane.C * unitToCheck.Position.Z + _ plane.D <= (-unitToCheck.Radius) Then Return False End If Next Return True End Function
The process of culling should take place before the unit is rendered. We accomplish this in the Render method of the BaseUnit class by checking the frustum of the camera before actually rendering the mesh. By placing it into the actual Render method, we avoid having to check the cull state elsewhere in the code.
Visual C#
if (camera.IsInViewFrustum ( this ) == false ) return;
Visual Basic
If camera.IsInViewFrustum(Me) = False Then Return End If
Now that we have the basic infrastructure in place, it's time to start adding units. But theUnitBase class is abstract, so it can not be instantiated. The entire purpose of the base class was to keep common unit properties and functionality together. Now it's time to create some classes that represent the objects we are going to use in BattleTank 2005, namely obstacles and tanks.
Visual C#
public class Obstacle : UnitBase { public Obstacle ( Device device, string meshFile, Vector3 position, float scale ) : base ( device, meshFile, position, scale ) { } }
Visual Basic
Public Class Obstacle Inherits UnitBase Public Sub New(ByVal device As Device, ByVal meshFile As String, _ ByVal position As Vector3, ByVal scale As Single) MyBase.New(device, meshFile, position, scale) End Sub End Class
The Obstacle class currently does nothing more than call its base class, but we are going add to this class later on.
Visual C#
public class Tank : UnitBase { public Tank ( Device device, string meshFile, Vector3 position, float scale ) : base ( device, meshFile, position, scale ) public void Update ( float deltaTime ) private float _speed = 10.0f; }
Visual Basic
Public Class Tank Inherits UnitBase Public Sub New(ByVal device As Device, ByVal meshFile As String, _ ByVal position As Vector3, ByVal scale As Single) MyBase.New(device, meshFile, position, scale) End Sub Public Sub Update(ByVal deltaTime As Single) MyBase.Z -= (m_speed * deltaTime) End Sub Private m_speed As Single = 10.0F End Class
The Tank class adds a
_speed
property we need to describe and an Update method.Using time to simulate movement
The Update method takes in a float that describes the amount of time in seconds that has passed since the last render loop. In the second article we added a variable calleddeltaTime which we used to compute the frame rate. This value is the value we want to use from now on to compute the position of moving objects. We use the principle of time to ensure a similar experience on each computer regardless of the speed of the computer and make the movement appear fluid. For example, if we updated the position of a moving object by 1 in each pass through the render loop, the object would move fast on faster computers since they are able to complete a render loop faster. You may have seen this behavior when experimenting with the rotating cube. Another problem is that each pass through the render loop is not performed at the same speed, depending on the other operations the CPU is performing, so that the movement may appear choppy. (Again, the easiest way to understand this is to experiment. Change the increment of the Z axis by one instead of the speed * time formula and see what happens.)
Notice that we only need to update the tanks since the obstacles do not move.
Visual C#
foreach ( Tank tank in _tanks ) { tank.Update ( _deltaTime ); }
Visual Basic
For Each tank As Tank In m_tanks tank.Update(m_deltaTime) Next
The actual Update method in the tank simply moves the tank towards the origin for now, using a predefined speed.
Visual C#
public void Update ( float deltaTime ) { base.Z -= ( _speed * deltaTime ); }
Visual Basic
Public Sub Update(ByVal deltaTime As Single) MyBase.Z -= (m_speed * deltaTime) End Sub
Next we create two generic collections in the GameEngine class to hold the mobile and stationery units.
Visual C#
private List<UnitBase> _obstacles; private List<UnitBase> _tanks;
Visual Basic
private List<UnitBase> _obstacles; private List<UnitBase> _tanks;
The actual units are added to the collection in the CreateObstacles and CreateTanksmethods.
Visual C#
private void CreateObstacles ( ) { _obstacles = new List<UnitBase> ( ); _obstacles.Add ( new Obstacle ( _device, @"car.x", new Vector3 ( 0, 0, 200 ), 1f ) ); _obstacles.Add ( new Obstacle ( _device, @"car.x", new Vector3 ( 60, 0, 100 ), 1f ) ); _obstacles.Add ( new Obstacle ( _device, @"car.x", new Vector3 ( -60, 0, 150 ), 1f ) ); _obstacles.Add ( new Obstacle ( _device, @"car.x", new Vector3 ( 60, 0, -100 ), 1f ) ); _obstacles.Add ( new Obstacle ( _device, @"car.x", new Vector3 ( -60, 0, -150 ), 1f ) ); } private void CreateTanks ( ) { _tanks = new List<UnitBase> ( ); _tanks.Add ( new Tank (_device, @"bigship1.x", new Vector3 ( 0, 0, 200 ), 1f ) ); _tanks.Add ( new Tank (_device, @"bigship1.x", new Vector3 ( 100, 0, 300 ), 1f ) ); _tanks.Add ( new Tank (_device, @"bigship1.x", new Vector3 ( -100, 0, 500 ), 1f ) ); _tanks.Add ( new Tank (_device, @"bigship1.x", new Vector3 ( 100, 0, -200 ), 1f ) ); _tanks.Add ( new Tank (_device, @"bigship1.x", new Vector3 ( -100, 0, -400 ), 1f ) ); }
Visual Basic
Private Sub CreateObstacles() m_obstacles = New List(Of UnitBase)() m_obstacles.Add(New Obstacle(m_device, "car.x", _ New Vector3(0, 0, 200), 1.0F)) m_obstacles.Add(New Obstacle(m_device, "car.x", _ New Vector3(60, 0, 100), 1.0F)) m_obstacles.Add(New Obstacle(m_device, "car.x", _ New Vector3(-60, 0, 150), 1.0F)) m_obstacles.Add(New Obstacle(m_device, "car.x", _ New Vector3(60, 0, -100), 1.0F)) m_obstacles.Add(New Obstacle(m_device, "car.x", _ New Vector3(-60, 0, -150), 1.0F)) End Sub Private Sub CreateTanks() m_tanks = New List(Of UnitBase) m_tanks.Add(New Tank(m_device, "bigship1.x", _ New Vector3(0, 0, 200),1.0F)) m_tanks.Add(New Tank(m_device, "bigship1.x", _ New Vector3(100, 0, 300), 1.0F)) m_tanks.Add(New Tank(m_device, "bigship1.x", _ New Vector3(-100, 0, 500), 1.0F)) m_tanks.Add(New Tank(m_device, "bigship1.x", _ New Vector3(100, 0, -200), 1.0F)) m_tanks.Add(New Tank(m_device, "bigship1.x", _ New Vector3(-100, 0, -400), 1.0F)) End Sub
If you look at the coordinates for the obstacles and tanks, you will see that I placed them along similar axes to help me orient myself. You could also modify these methods to create a random number of obstacles and tanks placed at random coordinates. Just make sure to add logic to avoid objects overlapping each other or being too close to the origin and obscuring the camera.
Levels
A more flexible and extensible solution is to read the number, type and location of obstacles and tanks from a file. This file could also contain other play-specific settings and be loaded automatically based on some internal logic. The most common use of this scenario is for predefined levels. A player would have the ability to advance to more difficult levels after meeting some completion criteria. This allows you to easily create an ever-changing play experience and fine-tune the playability of each level, something you can't do with random placement. Setting the game up that way also allows players to customize the game for themselves. You would normally provide a level editor when following this route.
The final step is to call these methods. The best place is the constructor of theGameEngine class right after the Camera class is created.
Visual C#
public GameEngine () { InitializeComponent ( ); this.SetStyle (ControlStyles.AllPaintingInWmPaint | ControlStyles.Opaque, true ); ConfigureInputDevices ( ); ConfigureDevice ( ); _skyBox = new SkyBox ( this._device ); _camera = new Camera ( ); CreateObstacles ( ); CreateTanks ( ); this.Size = new Size ( 800, 600 ); HiResTimer.Start ( ); }
Visual Basic
Public Sub New() InitializeComponent() Me.SetStyle(ControlStyles.AllPaintingInWmPaint Or _ ControlStyles.Opaque, True) ConfigureInputDevices() ConfigureDevice() m_skyBox = New SkyBox(Me.m_device) m_camera = New Camera CreateObstacles() CreateTanks() Me.Size = New Size(800, 600) HiResTimer.Start() End Sub
On each render loop all we have to do is to iterate over the appropriate collection and render those that are in the frustum. We call the RenderUnits method in the Rendermethod of the GameEngine class immediately after rendering the skybox.
Visual C#
private void RenderUnits ( ) { foreach ( UnitBase ub in _obstacles ) { ub.Render ( _camera ); } foreach ( UnitBase ub in _tanks ) { ub.Render ( _camera ); } }
Visual Basic
Private Sub RenderUnits() For Each ub As UnitBase In m_obstacles ub.Render(m_camera) Next For Each ub As UnitBase In m_tanks ub.Render(m_camera) Next End Sub
That's all. Now we have units.
Conclusion
If you run the game now you will notice three things.
- The units seem to float in mid-space.
- The units are all white.
- You can drive right through each unit.
The first problem can be solved by adding a terrain to the game to show a solid surface along the Y axis. The second problem can be solved by adding lights to the scene. The final issue will be solved once we add collision detection to the game. Other then these items, the game is getting more playable by the day. In the next article we are going to add the missing terrain using a heightmap, add lights so we can see the colors of the units, and add some collision detection.
As you may have noticed, I have changed (and will continue to do so) the various graphics in the game from article to article. This is intended to encourage you to implement your own graphics to change the underlying game into something completely different. You could easily change this game to be set in space, or turn it into a powerboat racing game by just changing the graphics. Give it a try.
As usual, I ran out of time. In the last article I had promised to add the HUD back, but I ran out of space. I will try to accomplish this in the coming articles. I also wanted to discuss Action mapping to enhance the way we are tracking the keyboard and mouse inputs, but this subject will have to be covered in a later article. In addition, I am planning to add a simple debugging console to the game.
Along with these features, the next articles are going to discuss adding Artificial Intelligence to the opposing tanks, making our game conform to realistic physical forces, and some cool sounds to make playing the game more fun. So stay tuned.
0 comments:
Post a Comment