
Since DirectX 9.0, a managed version of DirectX is available. Currently,
nearly all the samples available focus on C# and Visual Basic and not even
Microsoft's SDK provides tutorials or samples using MC++. Still, most serious
game programmers tend to use C++ because of the little bit of extra speed they
gain. In my opinion, Managed DirectX provides a quite nice and handy structure
for the programmer, especially for people who are new to Direct3D. If you
already are coding C++, there is no need to change to VB or C# - all the
information provided by the MSDN can easily be translated.
This article shows
how a 3D game can be done in managed C++ with a quite standard example - Tetris.
A graphics adapter which is likely to be fast enough to run a simple 3D game. Graphic modes can be configured. The source code uses DirectX 9.0b, but 9.0a will do as well. Of course, the latest .NET framework is required.
This will be the most interesting for you at the beginning anyways - and that's the best thing to do, because you'll know what I'm talking about later. So let me start off by briefly explaining how it works. First of all, you need to download the executable, of course. You must download the resources, too, and unzip them into the very same directory as the executable. They include graphic models and sound files.
The controls are pretty straight-forward: Use left/right to move the current tile, Up to rotate, down to drop the tile a little faster and space to drop it instantly. With the + and - keys, you can zoom a little and hitting P enables dynamic texture generation (blitting the contents of pr0.txt onto the frame's texture). For the rules, drop times and scoring, I used the information, which I have found on this site: http://www.colinfahey.com/2003jan_tetris/tetris_standard_specifications.htm.
The menu items are self-explanatory, so I won't waste your time with them here ;)
Of course, I cannot describe each line of the code, but there are lots of comments in case you want to re-use some things. The header files are even commented using XML Comments so a documentation can be generated as soon as VS supports that properly.
However, I tried to point out the most important - and in my view most interesting - pieces of code.
The source code only contains one namespace: Tetris3D. The whole game is structured into the following classes, most of them declared and defined in the equally named files:
| Class Name | Description |
|---|---|
| T3D | The main class. It contains objects of the other classes and provides the
basic application control functions like Initialize,
Run and CleanUp. |
| T3DWindow | Derived from System::Windows::Forms::Form - the main window. |
| T3DGraphics | This class contains the main render function and the code to initialize 3d graphics. |
| T3DSound | Contains functions to initialize and play sound buffers. |
| T3DKeyboard | Uses DirectInput to read the keyboard buffer and process controls. |
| T3DGame | Maintains the game field and applies the rules. |
| T3DBlock | Represents a Tetris tile. |
| T3DMenu | Provides the render and execution functionality for the menu. |
| T3DFrame | Loads and renders the frame mesh. |
| T3DIntro | Loads and renders the intro mesh. |
| T3DScoreBoard | Loads and renders the score board. |
| T3DGameover | Renders the game over screen. |
| T3DHighscore | Manages and renders the highscore list. |
| T3DConfiguration | Contains the dialog for the graphic device's configuration. |
Each of the classes (except for T3D) contains a private member
variable named parent, which is a pointer to the parent
T3D object. This allows each class to access any of the objects of
the current game instance.
If you are not new to game programming, you certainly know that nearly every game has a main loop which generally does the following: get input - react on input - output current screen. This main loop can be found inside the T3D::Run () function:
while (m_Window->Created && !bTerminate) {
// need to call tick function?
if(Environment::TickCount - nLastTimerCall >
T3D_Elapse [m_Game->nCurrentLevel] &&
m_Game->m_GameState == T3DGame::T3DGameStates::T3DGS_Running) {
nLastTimerCall = Environment::TickCount;
m_Game->Tick ();
}
// play outstanding falling row sounds
m_Sound->Tick ();
// process application events
Application::DoEvents ();
// get keys
if (m_Window->bActive)
m_Keyboard->ProcessKeys ();
Render ();
}
This main loop does pretty much the same as described above, but additionally
it adds a timed call to move the current tile down one unit and to play the
line-completed-sound again, if needed (if the player completes more than one row
at a time). There is also a call to Application::DoEvents, which
processes window messages.
The most interesting function is certainly the Render function,
but let's have a look at the game class and the graphics initializaton
first.
A Tetris implementation is not very difficult and this is certainly not the
best implementation around, but it does its job. The complete implementation can
be found in the T3DGame.h / cpp file and consists of the two classes
T3DGame and T3DBlock. Basically, a two-dimensional
array of the game field is kept (cells). Each cell inside the array
is either 0, indicating that there is no block there, or it specifies an index
inside the T3DGame::Colors array, which means that there is a block
with the given color. The class also contains two T3DBlock objects:
m_CurrentBlock and m_NextBlok. I will describe the
T3DBlock class in more detail later.
The game state is indicated by the m_GameState member variable.
Possible values are enumerated in the T3DGameStates enumeration.
CheckLines checks the cells array for complete lines,
removes them, and plays a sound effect.
Rows, score and level are stored in the nRows,
nScore, and nCurrentLevel member variables. These
values are updated by the CheckLines and
T3DBlock::Settle functions.
The most important function of the class is the Tick function.
It is called in specific intervals depending on the current level and it moves
the current block down one unit or settles it on the game field and spawns a new
block if no movement is possible.
The T3DBlock class provides the functions to validate and
execute a block's move. The color member variable contains the
block's color as an index in the T3DGame::Colors array and the
block's position is held by the x and y member
variables. rotation indicates the block's rotation (in 90 degree
steps).
The block's look is stored in the T3DGame::Blocks array. It can
be accessed like this:
unsigned short data = T3DGame::Blocks[rotation];
The data format is rather complex. You might already have noticed that each tile consists of four squares. Each of these squares has (local) coordinates between 0 and 3, that is, each coordinate can be represented using two bits. Therefore, each square can be represented using four bits, thus each block can be represented using 4 * 4 = 16 bits. Here's an example:
| 4 | ||||
| 2 | ||||
| 1 | ||||
| 0 | ||||
| 0 | 1 | 2 | 3 |
This gives us the four coordinate pairs: (1,1) (1,2) (2,1) (2,2) - or, in
binary format: (01,01) (01,10) (10,01) (10,10). That means that the data entry
would be: 0101011010011010 in binary format, or, in decimal format: 22170. I
have created those numbers for each of the seven blocks and for each possible
rotation. The T3DBlock::ToVirtualCoordinates function extracts
those coordinates and maps them to the game field.
When rendering, each of the squares is rendered as a seperate block mesh. The .x model file can be found in "gfx/cube.x".
Using the extracted coordinates, validating a move is quite easy. First, the
move is executed on a temporary copy of the current block and then the
coordinates of the temporary block's squares are extracted. If the
T3DGame::cells array is non-zero at any of the squares' positions
or the positions are out of range (x: smaller than 0, bigger or equal to 10; y:
smaller than 0, too large is impossible) that means that the movement is not
possible and the block is in its final position.
The T3DBlock::SpawnNew function uses the Random
class to create a new block. It also executes T3DBlock::ValidMove
to check whether a new tile can be created at all. If the creation of a new tile
fails, the game is over and the game over screen is shown. I will not go into
any details concerning the T3DGameover class as it is just another
example of mesh rendering.
To use managed Direct3D, the Microsoft::DirectX::Direct3D
namespace needs to be included. All graphics are rendered using a
Direct3D::Device object. This is stored in
T3DGraphics::device.
The device's configuration is done with the T3DConfiguration
class. It detects the installed adapters and supported graphic modes. It is done
using the Direct3D::Manager object, the code is easily
understandable, so I wont comment on it any further right now. The dialog stores
the settings that have been selected into the
T3DGraphics::m_PresentParams variable. This variable is used to
initialize the device, which is done in the T3DGraphics::Initialize
function:
try {
// create device
Console::Write ("Creating Device\t\t\t\t\t");
m_PresentParams->AutoDepthStencilFormat = DepthFormat::D16;
m_PresentParams->BackBufferCount = 1;
m_PresentParams->BackBufferFormat = Format::X8R8G8B8;
m_PresentParams->DeviceWindow = parent->m_Window;
m_PresentParams->DeviceWindowHandle = parent->m_Window->Handle;
m_PresentParams->EnableAutoDepthStencil = true;
m_PresentParams->MultiSample = MultiSampleType::None;
m_PresentParams->MultiSampleQuality = 0;
m_PresentParams->PresentationInterval = PresentInterval::Default;
m_PresentParams->PresentFlag = PresentFlag::None;
m_PresentParams->SwapEffect = SwapEffect::Flip;
m_PresentParams->Windowed = !parent->m_Configuration->checkBox1->Checked;
device = new Direct3D::Device (
m_Adapter,
devtype,
parent->m_Window,
CreateFlags::SoftwareVertexProcessing, m_PresentParams);
device->DeviceLost += new EventHandler (this, T3DGraphics::OnDeviceLost);
device->DeviceResizing += new System::ComponentModel::CancelEventHandler
(this, T3DGraphics::EnvironmentResize);
device->Disposing += new EventHandler (this, T3DGraphics::OnDisposing);
device->SetCursor (parent->m_Window->Cursor, false);
device->ShowCursor (false);
Console::WriteLine ("Ok");
} catch (Exception *e) {
Console::WriteLine (String::Format ("Failed:\n\t {0}", e->Message));
return false;
}
As you can see, a few settings like the back buffer and depth stencil formats
cannot be configured. After the device has been created using the
Direct3D::Device call, it is ready to use. A few event handlers are
added, they are needed for device lost exceptions (see Lost Devices). Finally,
the cursor is hidden.
The basic render function is T3D::Render. Here's the most
important excerpt:
// clear device
m_Graphics->device->Clear ((ClearFlags) (ClearFlags::Target |
ClearFlags::ZBuffer),
System::Drawing::Color::Black, 1.0f, 0);
// begin scene
m_Graphics->device->BeginScene ();
// execute render function
CurrentRenderFunction ();
// end scene
m_Graphics->device->EndScene ();
// present scene
try {
m_Graphics->device->Present ();
} catch (DeviceLostException *) { // device lost?
m_Graphics->bDeviceLost = true;
Console::WriteLine ("DeviceLost Exception caught.");
}
All the rendering needs to be done between the BeginScene and
EndScene calls. Finally, the scene is presented using the
Present function. CurrentRenderFunction is a pointer
to the current render code, which does mainly render the different meshes. An
example for such rendering can be found at "Loading and Using Meshes".
All meshes are loaded in the T3DGraphics::OnDeviceReset
function. Here is a representive excerpt of the function. The loading of all
other meshes is done exactly the same way:
// load mesh (block)
try {
m_Block = Mesh::FromFile (S"gfx/cube.x", (MeshFlags)0,
device);
} catch (Exception *) {
Console::WriteLine ("Error: Cannot load file \"gfx/cube.x\".");
return;
}
// create clone with normals
Mesh *TempMesh = m_Block->Clone (
(MeshFlags)(m_Block->Options.Use32Bit ? MeshFlags::Use32Bit : 0),
(VertexFormats)(m_Block->VertexFormat | VertexFormats::Normal),
device);
TempMesh->ComputeNormals ();
m_Block->Dispose ();
m_Block = TempMesh;
As you can see, the Mesh::FromFile function makes it quite easy
to load a .x file into a Mesh object. After the block mesh has been
loaded the meshes normals are computed. This is important for lighting the mesh
because a triangle's vertexes' normals affect the way light is reflected and
without the normals the object would appear in its ambient color and without any
shadows. Therefore, the mesh is first copied into a temporary mesh variable and
the normal flag is added to the vertex format. After that, the normals are
computed. As a reading on vertexes, normals and vertex formats I recommend this
site.
The block mesh is rendered in the T3DGraphics::DrawBlock
function:
// draw block at given position
void T3DGraphics::DrawBlock (float x, float y, float z, int c)
{
// setup view
device->Transform->World = Matrix::Translation (x, y, z);
// set material
Material m;
// make sure that the color is valid
Debug::Assert (c >= 0 && c < T3DGame::color_count);
m.Diffuse = Drawing::Color::FromArgb (T3DGame::Colors [c]);
m.Ambient = m.Diffuse;
// ...
device->Material = m;
// block
m_Block->DrawSubset (0);
}
I left out the setting of the material's emmisive color. This is just an
unimportant effect and it would take too much space here. Before drawing the
mesh, it needs to be transformed. Therefore, the
device->Transform->World variable is changed. For a block,
this is just a simple translation. Rotation could be added like this:
// rotate 180 degrees around the x-axis
DirectX::Matrix matWorld = DirectX::Matrix::RorationX ((float)Math::PI);
matWorld.Multiply (DirectX::Matrix::Translation (x, y, z)); // translate block
device->Transform->World = matWorld;
Using the Multiply functions, you can apply as many
transformations as you like. After translating the block, a material is assigned
and finally the mesh is drawn using the DrawSubset function. If a
mesh contains more than one material it is split into several subsets, but this
simple block contains only one subset. A more detailed description on using
meshes can be found here: http://msdn.microsoft.com/library/default.asp?url=/library/en-us/directx9_m/directx/direct3d/tutorials/tutorial6.asp.
If the user presses ALT-TAB in fullscreen mode the device gets lost. That
means that all the rendering functions will fail and
Device::Present will throw an exception. If you look at the above
T3D::Render code you will notice the try-catch block around this
function. It sets the T3DGraphics::bDeviceLost variable to true. If
a device is lost, an application should test whether it can gain control over
the device again and in that case reset the device again. This is also done in
the T3D::Render function:
if (m_Graphics->bDeviceLost) {
try {
m_Graphics->device->TestCooperativeLevel ();
} catch (DeviceLostException *) {
return;
} catch (DeviceNotResetException *) {
Console::WriteLine ("Resetting...");
m_Graphics->bDeviceLost = false;
m_Graphics->device->Reset (m_Graphics->m_PresentParams);
}
m_Graphics->bDeviceLost = false;
}
For further reading on lost devices, I recommend: http://msdn.microsoft.com/library/default.asp?url=/library/en-us/directx9_m/directx/direct3d/gettingstarted/devices/lostdevices.asp.
If you have already run the executable, you will have noticed the
three-dimensional font inside the scoreboard frame. Initially, I have created a
new mesh using the Mesh::TextFromFont function every frame.
However, after a few minutes this call somehow failed returning a mesh that
contained a few more vertexes, but not showing up at all. This was not a memory
error - I free'ed all the meshes properly. I searched the newgroups and found
someone else complaining about the same problem, but I did not find a solution.
This is why I decided to code a little workaround. The T3DFont
class generates a mesh for every graphical character. If text needs to be drawn,
it puts together all the generated meshes to form the requested string. No new
meshes are created, so the problem with Mesh::TextFromFont won't
occur.
This is a little gimmick that I couldn't resist. Hitting P while running the
game starts scrolling the contents of pr0.txt on the frame mesh. But besides the
gimmick effect, this is also a nice sample for generating textures at runtime.
The Texture class contains a FromBitmap function to
convert a Bitmap into a texture. At the beginning, I generated a bitmap each
frame containing the current graphical data and converted it to a texture using
this function. As you can imagine, this causes the framerate to drop horribly.
Currently, I use the Texture::GetSurfaceLevel function to retreive
a Surface object of the texture so I can modify it directly. Here's
the code:
// get graphics from bitmap
Surface *s = m_pr0Texture->GetSurfaceLevel (0);
Graphics *g = s->GetGraphics ();
// execute some GDI+ functions like this:
// clear bitmap
g->FillRegion (new SolidBrush (m_FrameMaterials[0].Material3D.Diffuse),
new Drawing::Region (Drawing::Rectangle (0, 0, pr0x, pr0y)));
// ...
// release graphics
s->ReleaseGraphics ();
s->Dispose ();
Of course, there is still a little framerate drop, but it works much better this way.
To use DirectSound, the Microsoft::DirectX::DirectSound
namespace needs to be referenced. All DirectSound functions can be found in the
T3DSound.h / cpp files.
Initializing the sound device is quite easy unless you need some special cooperative mode or something like that. For my purposes, those few lines did the job:
try {
device = new DirectSound::Device (); // use default device
device->SetCooperativeLevel (parent->m_Window,
DirectSound::CooperativeLevel::Normal);
} catch (Exception *) {
Console::WriteLine ("Failed");
return false;
}
SecondaryBuffer class. A secondary
sound buffer can be loaded like this: // load hit wave
try {
m_Knock = new SecondaryBuffer ("Sounds/Hit.wav", device);
} catch (Exception *) {
Console::WriteLine ("Failed: Could not load file \"Sounds/Hit.wav\"");
parent->bTerminate = true;
return false;
}
Playing the sound buffer is just as easy:
// play knock sound void T3DSound::PlayKnock () { if (!bSoundEffects) return; m_Knock->Play (0, BufferPlayFlags::Default); }
As further reading on sound buffers I recommend another MSDN page: http://msdn.microsoft.com/library/default.asp?url=/library/en-us/directx9_m/directx/sound/playingsounds/playing_sounds_entry.asp.
Unfortunately, the Tetris.mp3 music theme file is a quit big file to fit in a
sound buffer. Also, loading mp3 files is not possible with the
SecondaryBuffer class. Therefore I use the
DirectX::AudioVideoPlayback::Audio class:
// create tetris theme IMediaControler
m_TetrisTheme = new DirectX::AudioVideoPlayback::Audio ("Sounds/Tetris.mp3");
m_TetrisTheme->Play ();
That was all about sounds. A simple game like Tetris doesn't require 3D effects or anything alike ;)
The namespace that needs to be referenced to use DirectInput is
Microsoft::DirectX::DirectInput.
Again, initialization is really simple as the game does not require any special input capabilities:
try {
// create new device
device = new DirectInput::Device (SystemGuid::Keyboard);
} catch (Exception *e) {
Console::WriteLine ("Failure");
Windows::Forms::MessageBox::Show (e->Message);
return false;
}
// set cooperative level
device->SetCooperativeLevel (parent->m_Window,
(DirectInput::CooperativeLevelFlags)
(DirectInput::CooperativeLevelFlags::Foreground |
DirectInput::CooperativeLevelFlags::Exclusive));
// set buffersize
try {
device->Properties->BufferSize = buffer_size;
} catch (Exception *e) {
Console::WriteLine ("Failure");
Windows::Forms::MessageBox::Show (e->Message);
return false;
}
try {
device->Acquire ();
} catch (Exception *) {
}
The Acquire function fails, if the device is lost,
just like in graphics mode. Since keys are read in regularly, acquire is called
over and over again until it does not fail at some point of time. If the window
is not active, the keyboard buffer is not read in anyways.
T3DKeyboard::ProcessKeys
reads the buffered keyboard data and passes each key to a key processing
function which is assigned via the T3DKeyboard::KeyProcessor member
variable. Also, the code maintains a list of currently pressed keys, so key
press repetition can be simulated, because the device will not send buffered
data again, once a key is held down. try {
dataCollection = device->GetBufferedData ();
} catch (InputException *e) {
// device lost :/
// clear pressed key list
m_PressedKeys->Clear ();
try {
device->Acquire ();
} catch (InputLostException *) {
return;
} catch (InputException *ie) {
e = ie;
}
if (e) return;
}
// no data caught?
if (dataCollection) {
for (int i = 0; i < dataCollection->Count; i++) {
BufferedData *CurrentKey = dataCollection->Item [i];
if (dataCollection->Item [i]->Data & 0x80) { // key down
// add key to pressed list
m_PressedKeys->Add (CurrentKey);
} else { // key up
// remove key from list
for (int j = 0; j < m_PressedKeys->Count; j++) {
if (__try_cast <BufferedData *>
(m_PressedKeys->Item[j])->Offset == CurrentKey->Offset) {
m_PressedKeys->RemoveAt (j--);
}
}
}
// execute current key processor
if (KeyProcessor) {
KeyProcessor (CurrentKey);
}
}
}
// process outdated keys
for (int i = 0; i < m_PressedKeys->Count; i++) {
BufferedData *CurrentKey = __try_cast <BufferedData *>
(m_PressedKeys->Item[i]);
if (Environment::TickCount - CurrentKey->TimeStamp > KeyboardDelay)
if (KeyProcessor)
KeyProcessor (CurrentKey);
}
The key processor reacts on the input and moves the current tile, jumps to the next menu item or executes a menu item, for example.
August, 1st 2003: First version of the article.
So far, I have not discovered any bugs, but there certainly are some ;) Also, I have not tested the program on many environments, mostly WinXP with a relatively new graphics adapter and the latest DirectX SDK. So please let me know if you experience any problems.
Oliver Sluke for testing the game and giving lots of ideas for improvements.
Dark Nebula for the remix of the tetris theme. Other remixes and sound files can be found on his website: http://www.angelfire.com/darkside/darknebula/
| You must Sign In to use this message board. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||