Tetris, Tetris, Tetris


Tetris, Tetris, Tetris!

Enough with all the helper classes and game components discussions. It is time to write another cool game. Thanks to the many classes available in the little game engine it is now easy to write text on the screen, draw sprites, handle input, and play sounds.

Before going into the details of the Tetris game logic, it would be useful to think about the placement of all game elements in a similar way you did in the previous games. Instead of drawing all game components on the screen, you just show the background boxes to see what is going to be displayed. For the background you use the space background once again (I promise, this will be the last time). The background box is a new texture and exists in two modes (see Figure 4-7). It is used to separate the game components and make everything fit much nicer on the screen. You could also just reuse the same box for both parts of the game, but because the aspect ratio is so different for them it would either look bad for the background box or for the extra game components, which are smaller, but also need the background box graphic, just a smaller version of it.

image from book
Figure 4-7

Rendering the Background

To render these boxes on the screen you use the SpriteHelper class again and test everything with help of the following unit test:

  public static void TestBackgroundBoxes() {   TestGame.Start("TestBackgroundBoxes",     delegate     {   // Render background   TestGame.game.background.Render();          // Draw background boxes for all the components       TestGame.game.backgroundBigBox.Render(new Rectangle(         (512 - 200) - 15, 40 - 12, 400 + 23, (768 - 40) + 16));       TestGame.game.backgroundSmallBox.Render(new Rectangle(         (512 - 480) - 15, 40 - 10, 290 - 30, 300));       TestGame.game.backgroundSmallBox.Render(new Rectangle(         (512 + 240) - 15, 40 - 10, 290 - 30, 190));     }); } // TestBackgroundBoxes() 

This unit test will produce the output shown in Figure 4-8.

image from book
Figure 4-8

You might ask why the right box is a little bit smaller and where I got all these values from. Well, I just started with some arbitrary values and then improved the values until everything in the final game fit. First, the background is drawn in the unit test because you will not call the Draw method of TetrisGame if you are in the unit test (otherwise the unit tests won’t work anymore later when the game is fully implemented).

Then three boxes are drawn. The upper-left box is used to show the next block. The center box shows the current Tetris grid. And finally, the upper-right box is used to display the scoreboard. You already saw the unit test for that earlier.

Handling the Grid

It is time to fill the content of these boxes. Start with the main component: the TetrisGrid. This class is responsible for displaying the whole Tetris grid. It handles the input and moves the falling block and it shows all the existing data as well. You already saw which methods are used in the TetrisGrid class in the discussion about the game components. Before rendering the grid you should check out the first constants defined in the TetrisGrid class:

  #region Constants public const int GridWidth = 12; public const int GridHeight = 20; .. 

There are a couple more interesting constants, but for now you only need the grid dimensions. So you have 12 columns and 20 lines for your Tetris field. With help of the Block.png texture, which is just a simple quadratic block, you can now easily draw the full grid in the Draw method:

  // Calc sizes for block, etc. int blockWidth = gridRect.Width / GridWidth; int blockHeight = gridRect.Height / GridHeight; for ( int x=0; x<GridWidth; x++ )   for ( int y=0; y<GridHeight; y++ )   {     game.BlockSprite.Render(new Rectangle(       gridRect.X + x * blockWidth,       gridRect.Y + y * blockHeight,       blockWidth-1, blockHeight-1),       new Color(60, 60, 60, 128)); // Empty color   } // for for 

The gridRect variable is passed as a parameter to the Draw method from the main class to specify the area where you want the grid to be drawn to. It is the same rectangle as you used for the background box, just a little bit smaller to fit in. The first thing you are doing here is calculating the block width and height for each block you are going to draw. Then you go through the whole array and draw each block with the help of the SpriteHelper.Render method using a half transparent dark color to show an empty background grid. See Figure 4-9 to see how this looks. Because of the fact that you use game components you also don’t have to do all this code in your unit test. The unit test just draws the background box and then calls the TetrisGrid.Draw method to show the results (see the TestEmptyGrid unit test).

image from book
Figure 4-9

Block Types

Before you can render anything useful on your new grid you should think about the block types you can have in your game. The standard Tetris game has seven block types; all of them consist of four small blocks connected to each other (see Figure 4-10). The most favorite block type is of course the line type because you can kill up to four lines with that giving you the most points.

image from book
Figure 4-10

These block types have to be defined in the TetrisGrid class. One way of doing that is to use an enum holding all the possible block types. This enum can also hold an empty block type allowing you to use this data structure for the whole grid too because each grid block can contain either any part of the predefined block types or it is empty. Take a look at the rest of the constants in the TetrisGrid class:

  /// <summary> /// Block types we can have for each new block that falls down. /// </summary> public enum BlockTypes {   Empty,   Block,   Triangle,   Line,   RightT,   LeftT,   RightShape,   LeftShape, } // enum BlockTypes /// <summary> /// Number of block types we can use for each grid block. /// </summary> public static readonly int NumOfBlockTypes =   EnumHelper.GetSize(typeof(BlockTypes)); /// <summary> /// Block colors for each block type. /// </summary> public static readonly Color[] BlockColor = new Color[]   {     new Color( 60, 60, 60, 128 ), // Empty, color unused     new Color( 50, 50, 255, 255 ), // Line, blue     new Color( 160, 160, 160, 255 ), // Block, gray     new Color( 255, 50, 50, 255 ), // RightT, red     new Color( 255, 255, 50, 255 ), // LeftT, yellow     new Color( 50, 255, 255, 255 ), // RightShape, teal     new Color( 255, 50, 255, 255 ), // LeftShape, purple     new Color( 50, 255, 50, 255 ), // Triangle, green   }; // Color[] BlockColor /// <summary> /// Unrotated shapes /// </summary> public static readonly int[][,] BlockTypeShapesNormal = new int[][,]   {     // Empty     new int[,] { { 0 } },     // Line     new int[,] { { 0, 1, 0 }, { 0, 1, 0 }, { 0, 1, 0 }, { 0, 1, 0 } },     // Block     new int[,] { { 1, 1 }, { 1, 1 } },     // RightT     new int[,] { { 1, 1 }, { 1, 0 }, { 1, 0 } },     // LeftT     new int[,] { { 1, 1 }, { 0, 1 }, { 0, 1 } },     // RightShape     new int[,] { { 0, 1, 1 }, { 1, 1, 0 } },     // LeftShape     new int[,] { { 1, 1, 0 }, { 0, 1, 1 } },     // Triangle     new int[,] { { 0, 1, 0 }, { 1, 1, 1 }, { 0, 0, 0 } },   }; // BlockTypeShapesNormal 

BlockTypes is the enum we talked about; it contains all the possible block types and also is used to randomly generate new blocks in the NextBlock game component. Initially all of the grid fields are filled with the empty block type. The grid is defined as:

  /// <summary> /// The actual grid, contains all blocks, /// including the currently falling block. /// </summary> BlockTypes[,] grid = new BlockTypes[GridWidth, GridHeight]; 

By the way, NumOfBlockTypes shows you the usefulness of the enum class. You can easily determine how many entries are in the BlockTypes enum.

Next the colors for each block type are defined. These colors are used for the NextBlock preview, but also for rendering the whole grid. Each grid has a block type and you can easily use the BlockColors by converting the enum to an int number, which is used in the Draw method:

  BlockColor[(int)grid[x,y]] 

And finally the block shapes are defined, which looks a little bit more complicated, especially if you take into consideration that you have to allow these block parts to be rotated. This is done with help of the BlockTypeShapes, which is a big array of all possible blocks and rotations calculated in the constructor of TetrisGrid.

To add a new block to the Tetris grid you can just add each of the block parts to your grid, which is done in the AddRandomBlock method. You keep a separate list called floatingGrid to remember which parts of the grid have to be moved down (see the following section, “Gravity”; you can’t just let everything fall down) each time Update is called:

  // Randomize block type and rotation currentBlockType = (int)nextBlock.SetNewRandomBlock(); currentBlockRot = RandomHelper.GetRandomInt(4); // Get precalculated shape int[,] shape = BlockTypeShapes[currentBlockType,currentBlockRot]; int xPos = GridWidth/2-shape.GetLength(0)/2; // Center block at top most position of our grid currentBlockPos = new Point(xPos, 0); // Add new block for ( int x=0; x<shape.GetLength(0); x++ )   for ( int y=0; y<shape.GetLength(1); y++ )     if ( shape[x,y] > 0 )     {       // Check if there is already something       if (grid[x + xPos, y] != BlockTypes.Empty)       {         // Then game is over dude!         gameOver = true;     Sound.Play(Sound.Sounds.Lose);   } // if   else   {     grid[x + xPos, y] = (BlockTypes)currentBlockType;     floatingGrid[x + xPos, y] = true;   } // else } // for for if 

First you determine which block type you are going to add here. To help you do that you have a helper method in the NextBlock class, which randomizes the next block type and returns the last block type that was displayed in the NextBlock window. The rotation is also randomized; say “Hi” to the RandomHelper class.

With that data you can now get the precalculated shape and put it centered on the top of your grid. The two for loops iterate through the whole shape. It adds each valid part of the shape until you hit any existing data in the grid. In case that happens the game is over and you hear the lose sound. This will happen if the pile of blocks reaches the top of the grid and you cannot add any new blocks.

You now have the new block on your grid, but it is boring to just see it on the top there; it should fall down sometimes.

Gravity

To test the gravity of the current block the TestFallingBlockAndLineKill unit test is used. The active block is updated each time you call the Update method of TetrisGrid, which is not very often. In the first level the Update method is called only every 1000ms (every second). There you check if the current block can be moved down:

  // Try to move floating stuff down if (MoveBlock(MoveTypes.Down) == false ||   movingDownWasBlocked) {   // Failed? Then fix floating stuff, not longer moveable!   for ( int x=0; x<GridWidth; x++ )     for ( int y=0; y<GridHeight; y++ )       floatingGrid[x,y] = false;   Sound.Play(Sound.Sounds.BlockFalldown); } // if movingDownWasBlocked = false; 

Most of the Tetris logic is done in the MoveBlock helper method, which checks if moving in a specific direction is possible at all. If the block can’t be moved anymore it gets fixed and you clear the floatingGrid array and play the sound for landing a block on the ground.

After clearing the floatingGrid array there is no active block you can move down and the following code is used to check if a line was destroyed:

  // Check if we got any moveable stuff, // if not add new random block at top! bool canMove = false; for ( int x=0; x<GridWidth; x++ )   for ( int y=0; y<GridHeight; y++ )     if ( floatingGrid[x,y] )       canMove = true;        if (canMove == false) {   int linesKilled = 0;   // Check if we got a full line   for ( int y=0; y<GridHeight; y++ )   {     bool fullLine = true;     for ( int x=0; x<GridWidth; x++ )       if ( grid[x,y] == BlockTypes.Empty )       {         fullLine = false;         break;       } // for if            // We got a full line?     if (fullLine)     {       // Move everything down      for ( int yDown=y-1; yDown>0; yDown - )        for ( int x=0; x<GridWidth; x++ )          grid[x,yDown+1] = grid[x,yDown];                // Clear top line      for ( int x=0; x<GridWidth; x++ )        grid[0,x] = BlockTypes.Empty;              // Add 10 points and count line      score += 10;      lines++;      linesKilled++;      Sound.Play(Sound.Sounds.LineKill);    } // if   } // for      // If we killed 2 or more lines, add extra score   if (linesKilled >= 2)     score += 5;   if (linesKilled >= 3)     score += 10;   if (linesKilled >= 4)     score += 25;        // Add new block at top   AddRandomBlock(); } // if 

The first thing that is done here is to check if there is an active moving block. If not you go into the “if block,” checking if a full line is filled and can be destroyed. To determine if a line is filled you assume it is filled and then check if any block of the line is empty. Then you know that this line is not fully filled and continue checking the next line. If the line is filled, however, you remove it by copying all the lines above it down. This is the place where a nice explosion could occur. Anyway, the player gets 10 points for this line kill, and you hear the line kill sound.

If the player was able to kill more than one line he gets awarded more points. And finally the AddRandomBlock method you saw before is used to create a new block at the top.

Handling Input

Handling the user input itself is no big task anymore thanks to the Input helper class. You can easily check if a cursor or gamepad key was just pressed or is being held down. Escape and Back are handled in the BaseGame class and allow you to quit the game. Other than that you only need four keys in your Tetris game. To move to the left and right the cursor keys are used. The up cursor key is used to rotate the current block and the down cursor key or alternatively the space or A keys can be used to let the block fall down faster.

Similar to the gravity check to see if you can move the block down, the same check is done to see if you can move the current block left or right. Only if that is true do you actually move the block; this code is done in the TetrisGame Update method because you want to check for the player input every frame and not just when updating the TetrisGrid, which can only happen every 1000ms as you learned before. The code was in the TetrisGrid Update method before, but to improve the user experience it was moved and improved quite a lot also allowing you to move the block quickly left and right by hitting the cursor buttons multiple times.

Well, you have learned a lot about all the supporting code and you are almost doneto run the Tetris game for the first time. But you should take a look at the MoveBlock helper method because it is the most integral and important part of your Tetris game. Another important method is the RotateBlock method, which works in a similar way testing if a block can be rotated. You can check it out yourself in the source code for the Tetris game. Please use the unit tests in the TetrisGame class to see how all these methods work:

  #region Move block public enum MoveTypes {   Left,   Right,   Down, } // enum MoveTypes /// <summary> /// Remember if moving down was blocked, this increases /// the game speed because we can force the next block! /// </summary> public bool movingDownWasBlocked = false; /// <summary> /// Move current floating block to left, right or down. /// If anything is blocking, moving is not possible and /// nothing gets changed! /// </summary> /// <returns>Returns true if moving was successful, otherwise false</returns> public bool MoveBlock(MoveTypes moveType) {   // Clear old pos   for ( int x=0; x<GridWidth; x++ )     for ( int y=0; y<GridHeight; y++ )       if ( floatingGrid[x,y] )         grid[x,y] = BlockTypes.Empty; // Move stuff to new position bool anythingBlocking = false; Point[] newPos = new Point[4]; int newPosNum = 0; if ( moveType == MoveTypes.Left ) {   for ( int x=0; x<GridWidth; x++ )     for ( int y=0; y<GridHeight; y++ )       if ( floatingGrid[x,y] )       {         if ( x-1 < 0 ||           grid[x-1,y] != BlockTypes.Empty )           anythingBlocking = true;         else if ( newPosNum < 4 )         {           newPos[newPosNum] = new Point( x-1, y );           newPosNum++;         } // else if       } // for for if } // if (left) else if ( moveType == MoveTypes.Right ) {   for ( int x=0; x<GridWidth; x++ )     for ( int y=0; y<GridHeight; y++ )       if ( floatingGrid[x,y] )       {         if ( x+1 >= GridWidth ||           grid[x+1,y] != BlockTypes.Empty )           anythingBlocking = true;         else if ( newPosNum < 4 )         {           newPos[newPosNum] = new Point( x+1, y );           newPosNum++;         } // else if       } // for for if } // if (right) else if ( moveType == MoveTypes.Down ) {   for ( int x=0; x<GridWidth; x++ )     for ( int y=0; y<GridHeight; y++ )       if ( floatingGrid[x,y] )       {         if ( y+1 >= GridHeight ||           grid[x,y+1] != BlockTypes.Empty )           anythingBlocking = true;     else if ( newPosNum < 4 )         {           newPos[newPosNum] = new Point( x, y+1 );           newPosNum++;         } // else if       } // for for if       if ( anythingBlocking == true )        movingDownWasBlocked = true; } // if (down)    // If anything is blocking restore old state    if ( anythingBlocking ||      // Or we didn't get all 4 new positions?      newPosNum != 4 )    {      for ( int x=0; x<GridWidth; x++ )        for ( int y=0; y<GridHeight; y++ )         if ( floatingGrid[x,y] )           grid[x,y] = (BlockTypes)currentBlockType;     return false;   } // if   else   {      if ( moveType == MoveTypes.Left )        currentBlockPos =          new Point( currentBlockPos.X-1, currentBlockPos.Y );      else if ( moveType == MoveTypes.Right )        currentBlockPos =          new Point( currentBlockPos.X+1, currentBlockPos.Y );      else if ( moveType == MoveTypes.Down )        currentBlockPos =          new Point( currentBlockPos.X, currentBlockPos.Y+1 );                // Else we can move to the new position, lets do it!      for ( int x=0; x<GridWidth; x++ )        for ( int y=0; y<GridHeight; y++ )          floatingGrid[x,y] = false;      for ( int i=0; i<4; i++ )      {        grid[newPos[i].X,newPos[i].Y] = (BlockTypes)currentBlockType;        floatingGrid[newPos[i].X,newPos[i].Y] = true;      } // for      Sound.Play(Sound.Sounds.BlockMove);            return true;   } // else } // MoveBlock(moveType) #endregion 

There are three kinds of moves you can do: Left, Right, and Down. Each of these moves is handled in a separate code block to see if the left, right, or down data is available and if it is possible to move there. Before going into the details of this method there are two things that should be mentioned. First of all there is a helper variable called movingDownWasBlocked defined above the method. The reason you have this variable is to speed up the process of checking if the current block reached the ground, and it is stored at the class level to let the Update method pick it up later (which can be several frames later) and make the gravity code you saw earlier update much faster than in the case when the user doesn’t want to drop the block down right here. This is a very important part of the game because if each block were immediately fixed when reaching the ground the game would become very hard, and all the fun is lost when it gets faster and the grid gets more filled.

Then you use another trick to simplify the checking process by temporarily removing the current block from the grid. This way you can easily check if a new position is possible because your current position does not block you anymore. The code also uses several helper variables to store the new position and that code is simplified a bit to account for only four block parts. If you change the block types and the number of block parts, you should also change this method.

After setting everything up you check if the new virtual block position is possible in the three code blocks. Usually it is possible and you end up with four new values in the newPosNum array. If there are less than three values available you know that something was blocking you and the anythingBlocking variable is set to true anyway. In that case the old block position is restored and both the grid and the floatingGrid arrays stay the same way.

But in case the move attempt was successful the block position is updated and you clear the floatingGrid and finally add the block again to the new position by adding it both to the grid and the floatingGrid. The user also hears a very silent block move sound and you are done with this method.

Testing

With all that new code in the TetrisGrid class you can now test the unit tests in the TetrisGame class. In addition to the tests you saw before the two most important unit tests for the game logic are:

  • TestRotatingBlock, which tests the RotateBlock method of the TetrisGrid class.

  • TestFallingBlockAndKillLine, which is used to test the gravity and user input you just learned about.

It should be obvious that you often go back to older unit tests to update them according to the newest changes you require for your game. For example, the TestBackgroundBoxes unit test you saw earlier is very simple, but the layout and position of the background boxes changed quite a lot while implementing and testing the game components and it had to be updated accordingly to reflect the changes. One example for that would be the scoreboard, which is surrounded by the background box, but before you can know how big the scoreboard is going to be you have to know what the contents are and how much space they are going to consume. After writing the TestScoreboard method it became very obvious that the scoreboard has to be much smaller than the NextBlock background box, for example.

Another part of testing the game is constantly checking for bugs and improving the game code. The previous games were pretty simple and you only had to make minor improvements after the first initial runs, but the Tetris game is much more complex and you can spend many hours fixing and improving it.

One last thing you could test is running the game on the Xbox 360 - just select the Xbox 360 console platform in the project and try to run it on the Xbox 360. All the steps to do that were explained in Chapter 1, which also has a useful troubleshooting section in case something does not work on the Xbox 360. If you write new code you should make sure from time to time that it compiles on the Xbox 360 too. You are not allowed to write any interop code calling unmanaged assemblies, and some of the .NET 2.0 Framework classes and methods are missing on the Xbox 360.




Professional XNA Game Programming
Professional XNA Programming: Building Games for Xbox 360 and Windows with XNA Game Studio 2.0
ISBN: 0470261285
EAN: 2147483647
Year: 2007
Pages: 138

flylib.com © 2008-2017.
If you may any questions please contact us: flylib@qtcs.net