Building a Complete Board-based Puzzle Game with Microsoft XNA 4.0

Published February 01, 2012 by Kurt Jaegers, posted by GameDev.net
Do you see issues with this article? Let us know.
Advertisement
This article by Kurt Jaegers, author of XNA 4.0 Game Development by Example: Beginner's Guide, introduces a board-based puzzle game called Flood Control. We introduce the XNA Content Pipeline, and build a recursive function to determine the state of the game board while playing.

This article focuses on the following concepts:

  • Using the Content Pipeline to load textures from disk
  • Creating classes to divide code into logical units
  • Recursively evaluating the status of the game board to check for scoring chains
  • Drawing textures using the SpriteBatch.Draw() method
  • Managing simple game states







It was just another day at the bottom of the ocean until an explosion in one of the storage bays cracked the protective dome around Deep Sea Research Lab Alpha. Now the entire place is flooding, and the emergency pump system is a chaotic jumble of loose parts.

0669_02_01.png



Designing a puzzle game


The Puzzler has always been a popular game genre. From old standbys like Tetris to modern crazes like Bejeweled, puzzle games are attractive to players because they do not require a long-term time investment or a steep learning curve.

The game mechanic is the heart of any good puzzle game. This mechanic is usually very simple, with perhaps a few twists to keep the players on their toes.

In Flood Control, the player will be faced with a board containing 80 pieces of pipe. Some will be straight pipes and some will be curved. The objective of the game is to rotate the pipes to form a continuous line to pump water from the left side of the board to the right side of the board.

Completing a section of pipe drains water out of the base and scores points for the player, but destroys the pipes used. New pipes will fall into place for the player to begin another row.

Time for action - set up the Flood Control project

What just happened?


You have now set up a workspace for building Flood Control, and created a couple of folders for organizing game content. You have also imported the sample graphics for the Flood Control game into the project.

Introducing the Content Pipeline


The Flood ControlContent (Content) project inside Solution Explorer is a special kind of project called a Content Project. Items in your game's content project are converted into .XNB resource files by Content Importers and Content Processors.

If you right-click on one of the image files you just added to the Flood Control project and select Properties, you will see that for both the Importer and Processor, the Content Pipeline will use Texture - XNA Framework. This means that the Importer will take the file in its native format (.PNG in this case) and convert it to a format that the Processor recognizes as an image. The Processor then converts the image into an .XNB file which is a compressed binary format that XNA's content manager can read directly into a Texture2D object.

There are Content Importer/Processor pairs for several different types of content--images, audio, video, fonts, 3D models, and shader language effects files. All of these content types get converted to .XNB files which can be used at runtime.

0669_02_02.png



In order to see how to use the Content Pipeline at runtime, let's go ahead and write the code to read these textures into memory when the game starts:

Time for action - reading textures into memory


  1. Double-click on Game1.cs in Solution Explorer to open it or bring it to the front if it is already open.
  2. In the Class Declarations area of Game1 (right below SpriteBatch spriteBatch;), add:

    Texture2D playingPieces;
    Texture2D backgroundScreen;
    Texture2D titleScreen;

  3. Add code to load each of the Texture2D objects at the end of LoadContent():

    playingPieces = Content.Load(@"Textures\Tile_Sheet");
    backgroundScreen =
    Content.Load(@"Textures\Background");
    titleScreen = Content.Load(@"Textures\TitleScreen");


What just happened?


In order to load the textures from disk, you need an in-memory object to hold them. These are declared as instances of the Texture2D class.

A default XNA project sets up the Content instance of the ContentManager class for you automatically. The Content object's Load() method is used to read .XNB files from disk and into the Texture2D instances declared earlier.

One thing to note here is that the Load() method requires a type identifier, specified in angled brackets (), before the parameter list. Known in C# as a "Generic", many classes and methods support this kind of type specification to allow code to operate on a variety of data types. We will make more extensive use of Generics later when we need to store lists of objects in memory. The Load() method is used not only for textures, but also for all other kinds of content (sounds, 3D models, fonts, etc.) as well. It is important to let the Load() method know what kind of data you are reading so that it knows what kind of object to return.

Sprites and sprite sheets


As far as XNA and the SpriteBatch class are concerned, a sprite is a 2D bitmapped image that can be drawn either with or without transparency information to the screen.

[indent=1]Sprites vs. Textures
XNA defines a "sprite" as a 2D bitmap that is drawn directly to the screen. While these bitmaps are stored in Texture2D objects, the term "texture" is used when a 2D image is mapped onto a 3D object, providing a visual representation of the surface of the object. In practice, all XNA graphics are actually performed in 3D, with 2D sprites being rendered via special configurations of the XNA rendering engine.


The simple form of the SpriteBatch.Draw() call when drawing squares only needs three parameters: a Texture2D to draw, a Rectangle indicating where to draw it, and a Color to specify the tint to overlay onto the sprite.

Other overloads of the Draw() method, however, also allow you to specify a Rectangle representing the source area within the Texture2D to copy from. If no source Rectangle is specified, the entire Texture2D is copied and resized to fit the destination Rectangle.

[indent=1]Overloads
When multiple versions of the same method are declared with either different parameters lists or different return values, each different declaration is called an "overload" of the method. Overloads allow methods to work with different types of data (for example, when setting a position you could accept two separate X and Y coordinates or a Vector2 value), or leave out parameters that can then be assigned default values.


By specifying a source Rectangle, however, individual pieces can be pulled from a large image. A bitmap with multiple sprites on it that will be drawn this way is called a "sprite sheet".

The Tile_Sheet.png file for the Flood Control project is a sprite sheet containing 13 different sprites that will be used to represent the pieces of pipe used in the game. Each image is 40 pixels wide and 40 pixels high, with a one pixel border between each sprite and also around the entire image. When we call SpriteBatch.Draw() we can limit what gets drawn from our texture to one of these 40 by 40 squares, allowing a single texture to hold all of the playing piece images that we need for the game:

0669_02_03.png



The Tile_Sheet.png file was created with alpha-based transparency. When it is drawn to the screen, the alpha level of each pixel will be used to merge that pixel with any color that already occupies that location on the screen.

Using this fact, you can create sprites that don't look like they are rectangular. Internally, you will still be drawing rectangles, but visually the image can be of any shape.

What we really need now to be able to work with the playing pieces is a way to reference an individual piece, knowing not only what to draw to the screen, but what ends of the pipe connect to adjacent squares on the game board.

[indent=1]Alpha blending
Each pixel in a sprite can be fully opaque, fully transparent, or partially transparent. Fully opaque pixels are drawn directly, while fully transparent pixels are not drawn at all, leaving whatever has already been drawn to that pixel on the screen unchanged. In 32-bit color mode, each channel of a color (Red, Green, Blue, and Alpha) are represented by 8 bits, meaning that there are 256 different degrees of transparency between fully opaque (255) and fully transparent (0). Partially transparent pixels are combined with the current pixel color at that location to create a mixed color as if the pixels below were being seen through the new color.


Classes used in Flood Control


While it would certainly be possible to simply pile all of the game code into the Game1 class, the result would be difficult to read and manage later on. Instead, we need to consider how to logically divide the game into classes that can manage themselves and help to organize our code.

A good rule of thumb is that a class should represent a single thing or type of thing. If you can say "This object is made up of these other objects" or "This object contains these objects", consider creating classes to represent those relationships.

The Flood Control game contains a game board made up of 80 pipes. We can abstract these pipes as a class called GamePiece, and provide it with the code it needs to handle rotation and provide the code that will display the piece with a Rectangle that can be used to pull the sprite off the sprite sheet.

The game board itself can be represented by a GameBoard class, which will handle managing individual GamePiece objects and be responsible for determining which pieces should be filled with water and which ones should be empty.

The GamePiece class


The GamePiece class represents an individual pipe on the game board. One GamePiece has no knowledge of any other game pieces (that is the responsibility of the GameBoard class), but it will need to be able to provide information about the pipe to objects that use the GamePiece class. Our class has the following requirements:

  • Identify the sides of each piece that contain pipe connectors
  • Differentiate between game pieces that are filled with water and that are empty
  • Allow game pieces to be updated
  • Automatically handle rotation by changing the piece type to the appropriate new piece type
  • Given one side of a piece, provide the other sides of the piece in order to facilitate determining where water can flow through the game board
  • Provide a Rectangle that will be used when the piece is drawn, to locate the graphic for the piece on the sprite sheet

Identifying a GamePiece


While the sprite sheet contains thirteen different images, only twelve of them are actual game pieces (the last one is an empty square). Of the twelve remaining pieces, only six of them are unique pieces. The other six are the water-filled versions of the first six images.

Each of the game pieces can be identified by which sides of the square contain a connecting pipe. This results in two straight pieces and four pieces with 90 degree bends in them.

A second value can be tracked to determine if the piece is filled with water or not instead of treating filled pieces as separate types of pieces.

Time for action - build a GamePiece class - declarations


  1. Switch back to your Visual C# window if you have your image editor open.
  2. Right-click on Flood Control in Solution Explorer and select Add | Class...
  3. Name the class GamePiece.cs and click on Add.
  4. At the top of the GamePiece.cs file, add the following to the using directives already in the class:

    using Microsoft.Xna.Framework.Graphics;
    using Microsoft.Xna.Framework;

  5. In the class declarations section, add the following:

    public static string[] PieceTypes =
    {
    "Left,Right",
    "Top,Bottom",
    "Left,Top",
    "Top,Right",
    "Right,Bottom",
    "Bottom,Left",
    "Empty"
    };

    public const int PieceHeight = 40;
    public const int PieceWidth = 40;

    public const int MaxPlayablePieceIndex = 5;
    public const int EmptyPieceIndex = 6;

    private const int textureOffsetX = 1;
    private const int textureOffsetY = 1;
    private const int texturePaddingX = 1;
    private const int texturePaddingY = 1;

    private string pieceType = "";
    private string pieceSuffix = "";

  6. Add two properties to retrieve information about the piece:

    public string PieceType
    {
    get { return pieceType; }
    }

    public string Suffix
    {
    get { return pieceSuffix; }
    }


What just happened?


You have created a new code file called GamePiece.cs and included the using statements necessary to access the pieces of the XNA Framework that the class will use.

[indent=1]Using Directives
Adding the XNA Framework using directives at the top of the class file allows you to access classes like Rectangle and Vector2 without specifying their full assembly names. Without these statements, you would need Microsoft.Xna.Framework.Rectangle in your code every time you reference the type, instead of simply typing Rectangle.


In the declarations area, you have added an array called PieceTypes that gives a name to each of the different types of game pieces that will be added to the game board. There are two straight pieces, four angled pieces, and an empty tile with a background image on it, but no pipe. The array is declared as static because all instances of the GamePiece class will share the same array. A static member can be updated at execution time, but all members of the class will see the same changes.

Then, you have declared two integer constants that specify the height and width of an individual playing piece in pixels, along with two variables that specify the array index of the last piece that can be placed on the board (MaxPlayablePieceIndex) and of the fake "Empty" piece.

Next are four integers that describe the layout of the texture file you are using. There is a one pixel offset from the left and top edge of the texture (the one pixel border) and a single pixel of padding between each sprite on the sprite sheet.

[indent=1]Constants vs. Numeric literals
Why create constants for things like PieceWidth and PieceHeight and have to type them out when you could simply use the number 40 in their place? If you need to go back and resize your pieces later, you only need to change the size in one place instead of hoping that you find each place in the code where you entered 40 and change them all to something else. Even if you do not change the number in the game you are working on, you may reuse the code for something else later and having easily changeable parameters will make the job much easier.


There are only two pieces of information that each instance of GamePiece will track about itself--the type of the piece and any suffix associated with the piece. The instance members pieceType and pieceSuffix store these values. We will use the suffix to determine if the pipe that the piece represents is empty or filled with water.

However, these members are declared as private in order to prevent code outside the class from directly altering the values. To allow them to be read but not written to, we create a pair of properties (pieceType and pieceSuffix) that contain get blocks but no set blocks. This makes these values accessible in a read-only mode to code outside the GamePiece class.





Creating a GamePiece


The only information we need to create an instance of GamePiece is the piece type and, potentially, the suffix.

Time for action - building a GamePiece class: constructors


  1. Add two constructors to your GamePiece.cs file after the declarations:

    public GamePiece(string type, string suffix)
    {
    pieceType=type;
    pieceSuffix=suffix;
    }

    public GamePiece(string type)
    {
    pieceType=type;
    pieceSuffix="";
    }


What just happened?


A constructor is run when an instance of the GamePiece class is created. By specifying two constructors, we will allow future code to create a GamePiece by specifying a piece type with or without a suffix. If no suffix is specified, an empty suffix is assumed.

Updating a GamePiece


When a GamePiece is updated, you can change the piece type, the suffix, or both.

Time for action - GamePiece class methods - part 1 - updating


  1. Add the following methods to the GamePiece class:

    public void SetPiece(string type, string suffix)
    {
    pieceType = type;
    pieceSuffix = suffix;
    }

    public void SetPiece(string type)
    {
    SetPiece(type,"");
    }

    public void AddSuffix(string suffix)
    {
    if (!pieceSuffix.Contains(suffix))
    pieceSuffix += suffix;
    }

    public void RemoveSuffix(string suffix)
    {
    pieceSuffix = pieceSuffix.Replace(suffix, "");
    }


The first two methods are overloads with the same name, but different parameter lists. In a manner similar to the GamePiece constructors, code that wishes to update a GamePiece can pass it a piece type, and optionally a suffix.

Additional methods have been added to modify suffixes without changing the pieceType associated with the piece. The AddSuffix() method first checks to see if the piece already contains the suffix. If it does, nothing happens. If it does not, the suffix value passed to the method is added to the pieceSuffix member variable.

The RemoveSuffix() method uses the Replace() method of the string class to remove the passed suffix from the pieceSuffix variable.

Rotating pieces


The heart of the Flood Control play mechanic is the ability of the player to rotate pieces on the game board to form continuous pipes. In order to accomplish this, we can build a table that, given an existing piece type and a rotation direction, supplies the name of the piece type after rotation. We can then implement this code as a switch statement:

0669_02_04.png



Time for action - GamePiece class methods - part 2 - rotation


  1. Add the RotatePiece() method to the GamePiece class:

    public void RotatePiece(bool Clockwise)
    {
    switch (pieceType)
    {
    case "Left,Right":
    pieceType = "Top,Bottom";
    break;
    case "Top,Bottom":
    pieceType = "Left,Right";
    break;
    case "Left,Top":
    if (Clockwise)
    pieceType = "Top,Right";
    else
    pieceType = "Bottom,Left";
    break;
    case "Top,Right":
    if (Clockwise)
    pieceType = "Right,Bottom";
    else
    pieceType = "Left,Top";
    break;
    case "Right,Bottom":
    if (Clockwise)
    pieceType = "Bottom,Left";
    else
    pieceType = "Top,Right";
    break;
    case "Bottom,Left":
    if (Clockwise)
    pieceType = "Left,Top";
    else
    pieceType = "Right,Bottom";
    break;
    case "Empty":
    break;
    }
    }


What just happened?


The only information the RotatePiece() method needs is a rotation direction. For straight pieces, rotation direction doesn't matter (a left/right piece will always become a top/bottom piece and vice versa).

For angled pieces, the piece type is updated based on the rotation direction and the diagram above.

[indent=1]Why all the strings?
It would certainly be reasonable to create constants that represent the various piece positions instead of fully spelling out things like Bottom,Left as strings. However, because the Flood Control game is not taxing on the system, the additional processing time required for string manipulation will not impact the game negatively and helps clarify how the logic works.


Pipe connectors


Our GamePiece class will need to be able to provide information about the connectors it contains (Top, Bottom, Left, and Right) to the rest of the game. Since we have represented the piece types as simple strings, a string comparison will determine what connectors the piece contains.

Time for action - GamePiece class methods - part 3 -connection methods


  1. Add the GetOtherEnds() method to the GamePiece class:

    public string[] GetOtherEnds(string startingEnd)
    {
    List opposites = new List();

    foreach (string end in pieceType.Split(','))
    {
    if (end != startingEnd)
    opposites.Add(end);
    }
    return opposites.ToArray();
    }

  2. Add the HasConnector() method to the GamePiece class:

    public bool HasConnector(string direction)
    {
    return pieceType.Contains(direction);
    }


The GetOtherEnds() method creates an empty List object for holding the ends we want to return to the calling code. It then uses the Split() method of the string class to get each end listed in the pieceType. For example, the Top,Bottom piece will return an array with two elements. The first element will contain Top and the second will contain Bottom. The comma delimiter will not be returned with either string.

If the end in question is not the same as the startingEnd parameter that was passed to the method, it is added to the list. After all of the items in the string have been examined, the list is converted to an array and returned to the calling code.

In the previous example, requesting GetOtherEnds("Top") from a GamePiece with a pieceType value of Top,Bottom will return a string array with a single element containing Bottom.

We will need this information in a few moments when we have to figure out which pipes are filled with water and which are empty.

The second function, HasConnector() simply returns "true" if the pieceType string contains the string value passed in as the direction parameter. This will allow code outside the GamePiece class to determine if the piece has a connector facing in any particular direction.

Sprite sheet coordinates


Because we set up the PieceTypes array listing the pieces in the same order that they exist on the sprite sheet texture, we can calculate the position of the rectangle to draw from based on the pieceType.

Time for action - GamePiece class methods - part 4 - GetSourceRect


  1. Add the GetSourceRect() method to the GamePiece class:

    public Rectangle GetSourceRect()
    {
    int x = textureOffsetX;
    int y = textureOffsetY;

    if (pieceSuffix.Contains("W"))
    x += PieceWidth + texturePaddingX;

    y += (Array.IndexOf(PieceTypes, pieceType) *
    (PieceHeight + texturePaddingY));


    return new Rectangle(x, y, PieceWidth, PieceHeight);
    }


What just happened?


Initially, the x and y variables are set to the textureOffsets that are listed in the GamePiece class declaration. This means they will both start with a value of one.

Because the sprite sheet is organized with a single type of pipe on each row, the x coordinate of the Rectangle is the easiest to determine. If the pieceSuffix variable does not contain a W (signifying that the piece is filled with water), the x coordinate will simply remain 1.

If the pieceSuffix does contain the letter W (indicating the pipe is filled), the width of a piece (40 pixels), along with the padding between the pieces (1 pixel), are added to the x coordinate of the source Rectangle. This shifts the x coordinate from 1 to a value of 1 + 40 + 1, or 42 which corresponds to the second column of images on the sprite sheet.

To determine the y coordinate for the source rectangle, Array.IndexOf(PieceTypes, pieceType) is used to locate the pieceType within the PieceTypes array. The index that is returned represents the position of the tile on the sprite sheet (because the array is organized in the same order as the pieces on the image). For example, Left,Right returns zero, while Top,Bottom returns one and Empty returns six.

The value of this index is multiplied by the height of a game piece plus the padding between pieces. For our sprite sheet, an index of 2 (the Left,Top piece) would be multiplied by 41 (PieceHeight of 40 plus texturePaddingY of 1) resulting in a value of 82 being added to the y variable.

Finally, the new Rectangle is returned, comprised of the calculated x and y coordinates and the predefined width and height of a piece:

0669_02_05.png



The GameBoard class


Now that we have a way to represent pieces in memory, the next logical step is to create a way to represent an entire board of playing pieces.

The game board is a two-dimensional array of GamePiece objects, and we can build in some additional functionality to allow our code to interact with pieces on the game board by their X and Y coordinates.

The GameBoard class needs to:

  • Store a GamePiece object for each square on the game board
  • Provide methods for code using the GameBoard to update individual pieces by passing calls through to the underlying GamePiece instances
  • Randomly assign a piece type to a GamePiece
  • Set and clear the "Filled with water" flags on individual GamePieces
  • Determine which pipes should be filled with water based on their position and orientation and mark them as filled
  • Return lists of potentially scoring water chains to code using the GameBoard

Time for action - create the GameBoard.cs class


  1. As you did to create the GamePiece class, right-click on Flood Control in Solution Explorer and select Add | Class... Name the new class file GameBoard.cs.
  2. Add the using directive for the XNA framework at the top of the file:

    using Microsoft.Xna.Framework;

  3. Add the following declarations to the GameBoard class:

    Random rand = new Random();

    public const int GameBoardWidth = 8;
    public const int GameBoardHeight = 10;

    private GamePiece[,] boardSquares =
    new GamePiece[GameBoardWidth, GameBoardHeight];

    private List WaterTracker = new List();


What just happened?


We used the Random class in SquareChase to generate random numbers. Since we will need to randomly generate pieces to add to the game board, we need an instance of Random in the GameBoard class.

The two constants and the boardSquares array provide the storage mechanism for the GamePiece objects that make up the 8 by 10 piece board.

Finally, a List of Vector2 objects is declared that we will use to identify scoring pipe combinations. The List class is one of C#'s Generic Collection classes--classes that use the Generic templates (angle brackets) we first saw when loading a texture for SquareChase. Each of the Collection classes can be used to store multiple items of the same type, with different methods to access the entries in the collection. We will use several of the Collection classes in our projects. The List class is much like an array, except that we can add any number of values at runtime, and remove values in the List if necessary.

A Vector2 is a structure defined by the XNA Framework that holds two floating point values, X and Y. Together the two values represent a vector pointing in any direction from an imaginary origin (0, 0) point. We will use Vector2 structures to represent the locations on our game board in Flood Control, placing the origin in the upper left corner of the board.

Creating the game board


If we were to try to use any of the elements in the boardSquares array, at this point, we would get a Null Reference exception because none of the GamePiece objects in the array have actually been created yet.

Time for action - initialize the game board


  1. Add a constructor to the GameBoard class:

    public GameBoard()
    {
    ClearBoard();
    }

  2. Add the ClearBoard() helper method to the GameBoard class:

    public void ClearBoard()
    {
    for (int x = 0; x for (int y = 0; y boardSquares[x, y] = new GamePiece("Empty");
    }


What just happened?


When a new instance of the GameBoard class is created, the constructor calls the ClearBoard() helper method, which simply creates 80 empty game pieces and assigns them to each element in the array.

[indent=1]Helper methods
Why not simply put the two for loops that clear the board into the GameBoard constructor? Splitting the work into methods that accomplish a single purpose greatly helps to keep your code both readable and maintainable. Additionally, by splitting ClearBoard() out as its own method we can call it separately from the constructor. When we add increasing difficulty levels, we make use of this call when a new level starts.


Updating GamePieces


The boardSquares array in the GameBoard class is declared as a private member, meaning that the code that uses the GameBoard will not have direct access to the pieces contained on the board.

In order for code in our Game1 class to interact with a GamePiece, we will need to create public methods in the GameBoard class that expose the pieces in boardSquares.

Time for action - manipulating the game board


  1. Add public methods to the GameBoard class to interact with GamePiece:

    public void RotatePiece(int x, int y, bool clockwise)
    {
    boardSquares[x, y].RotatePiece(clockwise);
    }

    public Rectangle GetSourceRect(int x, int y)
    {
    return boardSquares[x, y].GetSourceRect();
    }

    public string GetSquare(int x, int y)
    {
    return boardSquares[x, y].PieceType;
    }

    public void SetSquare(int x, int y, string pieceName)
    {
    boardSquares[x, y].SetPiece(pieceName);
    }

    public bool HasConnector(int x, int y, string direction)
    {
    return boardSquares[x, y].HasConnector(direction);
    }

    public void RandomPiece(int x, int y)
    {
    boardSquares[x, y].SetPiece(GamePiece.PieceTypes[rand.Next(0,
    GamePiece.MaxPlayablePieceIndex+1)]);
    }


What just happened?


RotatePiece(), GetSourceRect(), GetSquare(), SetSquare(), and HasConnector() methods simply locate the appropriate GamePiece within the boardSquares array and pass on the function request to the piece.

The RandomPiece() method uses the rand object to get a random value from the PieceTypes array and assign it to a GamePiece. It is important to remember that with the Random.Next() method overload used here, the second parameter is non-inclusive. In order to generate a random number from 0 through 5, the second parameter needs to be 6.

Filling in the gaps


Whenever the player completes a scoring chain, the pieces in that chain are removed from the board. Any pieces above them fall down into the vacated spots and new pieces are generated.

Time for action - filling in the gaps


  1. Add the FillFromAbove() method to the GameBoard class.

    public void FillFromAbove(int x, int y)
    {
    int rowLookup = y - 1;

    while (rowLookup >= 0)
    {
    if (GetSquare(x, rowLookup) != "Empty")
    {
    SetSquare(x, y,
    GetSquare(x, rowLookup));
    SetSquare(x, rowLookup, "Empty");
    rowLookup = -1;
    }
    rowLookup--;
    }
    }


What just happened?


Given a square to fill, FillFromAbove() looks at the piece directly above to see if it is marked as Empty. If it is, the method will subtract one from rowLookup and start over until it reaches the top of the board. If no non-empty pieces are found when the top of the board is reached, the method does nothing and exits.

When a non-empty piece is found, it is copied to the destination square, and the copied piece is changed to an empty piece. The rowLookup variable is set to -1 to ensure that the loop does not continue to run.

Generating new pieces


We can create a single method that will fill any empty spaces on the game board, and use it when the game begins and when pieces are removed from the board after scoring.

Time for action - generating new pieces


  1. Add the GenerateNewPieces() method to the GameBoard class:

    public void GenerateNewPieces(bool dropSquares)
    {

    if (dropSquares)
    {
    for (int x = 0; x {
    for (int y = GameBoard.GameBoardHeight - 1; y >= 0; y--)
    {
    if (GetSquare(x, y) == "Empty")
    {
    FillFromAbove(x, y);
    }
    }
    }
    }

    for (int y = 0; y for (int x = 0; x {
    if (GetSquare(x, y) == "Empty")
    {
    RandomPiece(x, y);
    }
    }
    }


What just happened?


When GenerateNewPieces() is called with "true" passed as dropSquares, the looping logic processes one column at a time from the bottom up. When it finds an empty square it calls FillFromAbove() to pull a filled square from above into that location.

The reason the processing order is important here is that, by filling a lower square from a higher position, that higher position will become empty. It, in turn, will need to be filled from above.

After the holes are filled (or if dropSquares is set to false) GenerateNewPieces() examines each square in boardSquares and asks it to generate random pieces for each square that contains an empty piece.





Water filled pipes


Whether or not a pipe is filled with water is managed separately from its orientation. Rotating a single pipe could change the water-filled status of any number of other pipes without changing their rotation.

Instead of filling and emptying individual pipes, however, it is easier to empty all of the pipes and then refill the pipes that need to be marked as having water in them.

Time for action - water in the pipes


  1. Add a method to the GameBoard class to clear the water marker from all pieces:

    public void ResetWater()
    {
    for (int y = 0; y for (int x = 0; x boardSquares[x,y].RemoveSuffix("W");
    }

  2. Add a method to the GameBoard class to fill an individual piece with water:

    public void FillPiece(int X, int Y)
    {
    boardSquares[X,Y].AddSuffix("W");
    }


What just happened?


The ResetWater() method simply loops through each item in the boardSquares array and removes the W suffix from the GamePiece. Similarly, to fill a piece with water, the FillPiece() method adds the W suffix to the GamePiece. Recall that by having a W suffix, the GetSourceRect() method of GamePiece shifts the source rectangle one tile to the right on the sprite sheet, returning the image for a pipe filled with water instead of an empty pipe.

Propagating water


Now that we can fill individual pipes with water, we can write the logic to determine which pipes should be filled depending on their orientation.

Time for action - making the connection


  1. Add the PropagateWater() method to the GameBoard class:

    public void PropagateWater(int x, int y, string fromDirection)
    {
    if ((y >= 0) && (y (x >= 0) && (x {
    if (boardSquares[x,y].HasConnector(fromDirection) &&
    !boardSquares[x,y].Suffix.Contains("W"))
    {
    FillPiece(x, y);
    WaterTracker.Add(new Vector2(x, y));
    foreach (string end in
    boardSquares[x,y].GetOtherEnds(fromDirection))
    switch (end)
    {
    case "Left": PropagateWater(x - 1, y, "Right");
    break;
    case "Right": PropagateWater(x + 1, y, "Left");
    break;
    case "Top": PropagateWater(x, y - 1, "Bottom");
    break;
    case "Bottom": PropagateWater(x, y + 1, "Top");
    break;
    }
    }
    }
    }

  2. Add the GetWaterChain() method to the GameBoard class:

    public List GetWaterChain(int y)
    {
    WaterTracker.Clear();
    PropagateWater(0, y, "Left");
    return WaterTracker;
    }


What just happened?


Together, GetWaterChain() and PropagateWater() are the keys to the entire Flood Control game, so understanding how they work is vital. When the game code wants to know if the player has completed a scoring row, it will call the GetWaterChain() method once for each row on the game board:

0669_02_06.png



The WaterTracker list is cleared and GetWaterChain() calls PropagateWater() for the first square in the row, indicating that the water is coming from the Left direction.

The PropagateWater() method checks to make sure that the x and y coordinates passed to it exist within the board and, if they do, checks to see if the piece at that location has a connector matching the fromDirection parameter and that the piece is not already filled with water. If all of these conditions are met, that piece gets filled with water and added to the WaterTracker list.

Finally, PropagateWater() gets a list of all other directions that the piece contains (in other words, all directions the piece contains that do not match fromDirection). For each of these directions PropagateWater() recursively calls itself, passing in the new x and y location as well as the direction the water is coming from.

Building the game


We now have the component classes we need to build the Flood Control game, so it is time to bring the pieces together in the Game1 class.

Declarations


We only need a handful of game-wide declarations to manage things like the game board, the player's score, and the game state.

Time for action - Game1 declarations


  1. Double click on the Game1.cs file in Solution Explorer to reactivate the Game1.cs code file window.
  2. Add the following declarations to the Game1 class member declaration area:

    GameBoard gameBoard;

    Vector2 gameBoardDisplayOrigin = new Vector2(70, 89);

    int playerScore = 0;

    enum GameStates { TitleScreen, Playing };
    GameStates gameState = GameStates.TitleScreen;

    Rectangle EmptyPiece = new Rectangle(1, 247, 40, 40);

    const float MinTimeSinceLastInput = 0.25f;
    float timeSinceLastInput = 0.0f;


What just happened?


The gameBoard instance of GameBoard will hold all of the playing pieces, while the gameBoardDisplayOrigin vector points to where on the screen the board will be drawn. Using a vector like this makes it easy to move the board in the event that you wish to change the layout of your game screen.

As we did in SquareChase, we store the player's score and will display it in the window title bar.

In order to implement a simple game state mechanism, we define two game states. When in the TitleScreen state, the game's title image will be displayed and the game will wait until the user presses the Space bar to start the game. The state will then switch to Playing, which will display the game board and allow the user to play.

If you look at the sprite sheet for the game, the pipe images themselves do not cover the entire 40x40 pixel area of a game square. In order to provide a background, an empty tile image will be drawn in each square first. The EmptyPiece Rectangle is a convenient pointer to where the empty background is located on the sprite sheet.

Just as we used an accumulating timer in SquareChase to determine how long to leave a square in place before moving it to a new location, we will use the same timing mechanism to make sure that a single click by the user does not send a game piece spinning unpredictably. Remember that the Update() method will be executing up to 60 times each second, so slowing the pace of user input is necessary to make the game respond in a way that feels natural.

Initialization


Before we can use the gameBoard instance, it needs to be initialized. We will also need to enable the mouse cursor.

Time for action - updating the Initialize() method


  1. Update the Initialize() method to include the following:

    this.IsMouseVisible = true;
    graphics.PreferredBackBufferWidth = 800;
    graphics.PreferredBackBufferHeight = 600;
    graphics.ApplyChanges();
    gameBoard = new GameBoard();


What just happened?


After making the mouse cursor visible, we set the size of the BackBuffer to 800 by 600 pixels. On Windows, this will size the game window to 800 by 600 pixels as well.

The constructor for the GameBoard class calls the ClearBoard() member, so each of the pieces on the gameBoard instance will be set to Empty.

The Draw() method - the title screen


In the declarations section, we established two possible game states. The first (and default) state is GameStates.TitleScreen, indicating that the game should not be processing actual game play, but should instead be displaying the game's logo and waiting for the user to begin the game.

Time for action - drawing the screen - the title screen

  1. Modify the Draw() method of Game1 to include the code necessary to draw the game's title screen after GraphicsDevice.Clear(Color.CornflowerBlue);

    if (gameState == GameStates.TitleScreen)
    {
    spriteBatch.Begin();
    spriteBatch.Draw(titleScreen,
    new Rectangle(0, 0,
    this.Window.ClientBounds.Width,
    this.Window.ClientBounds.Height),
    Color.White);
    spriteBatch.End();
    }

  2. Run the game and verify that the title screen is displayed. You will not be able to start the game however, as we haven't written the Update() method yet.
  3. Stop the game by pressing Alt + F4.

0669_02_07.png



What just happened?


The title screen is drawn with a single call to the Draw() method of the spriteBatch object. Since the title screen will cover the entire display, a rectangle is created that is equal to the width and height of the game window.

The Draw() method - the play screen


Finally, we are ready to display the playing pieces on the screen. We will accomplish this by using a simple loop to display all of the playing pieces in the gameBoard object.

Time for action - drawing the screen - the play screen


  1. Update the Draw() method of the Game1 class to add the code to draw the game board after the code that draws the title screen:

    if (gameState == GameStates.Playing)
    {
    spriteBatch.Begin();

    spriteBatch.Draw(backgroundScreen,
    new Rectangle(0, 0,
    this.Window.ClientBounds.Width,
    this.Window.ClientBounds.Height),
    Color.White);

    for (int x = 0; x for (int y = 0; y {
    int pixelX = (int)gameBoardDisplayOrigin.X +
    (x * GamePiece.PieceWidth);
    int pixelY = (int)gameBoardDisplayOrigin.Y +
    (y * GamePiece.PieceHeight);

    spriteBatch.Draw(
    playingPieces,
    new Rectangle(
    pixelX,
    pixelY,
    GamePiece.PieceWidth,
    GamePiece.PieceHeight),
    EmptyPiece,
    Color.White);

    spriteBatch.Draw(
    playingPieces, new Rectangle(
    pixelX,
    pixelY,
    GamePiece.PieceWidth,
    GamePiece.PieceHeight),
    gameBoard.GetSourceRect(x, y),
    Color.White);
    }

    this.Window.Title = playerScore.ToString();

    spriteBatch.End();
    }


What just happened?


As you can see, the code to draw the game board begins exactly like the code to draw the title screen. Since we are using a background image that takes up the full screen, we draw it exactly the same way as the title screen.

Next, we simply loop through gameBoard to draw the squares. The pixelX and pixelY variables are calculated to determine where on the screen each of the game pieces will be drawn. Since both x and y begin at 0, the (x * GamePiece.PieceWidth) and (y * GamePiece.PieceHeight) will also be equal to zero, resulting in the first square being drawn at the location specified by the gameBoardDisplayOrigin vector.

As x increments, each new piece is drawn 40 pixels further to the right than the previous piece. After a row has been completed, the y value increments, and a new row is started 40 pixels below the previous row.

The first spriteBatch.Draw() call uses Rectangle(pixelX, pixelY, GamePiece.PieceWidth, GamePiece.PieceHeight) as the destination rectangle and EmptyPiece as the source rectangle. Recall that we added this Rectangle to our declarations area as a shortcut to the location of the empty piece on the sprite sheet.

The second spriteBatch.Draw() call uses the same destination rectangle, overlaying the playing piece image onto the empty piece that was just drawn. It asks the gameBoard to provide the source rectangle for the piece it needs to draw.

The player's score is displayed in the window title bar, and spriteBatch.End() is called to finish up the Draw() method.

Keeping score


Longer chains of filled water pipes score the player more points. However, if we were to simply assign a single point to each piece in the pipe chain, there would be no scoring advantage to making longer chains versus quickly making shorter chains.

Time for action - scores and scoring chains


  1. Add a method to the Game1 class to calculate a score based on the number of pipes used:

    private int DetermineScore(int SquareCount)
    {
    return (int)((Math.Pow((SquareCount/5), 2) + SquareCount)*10);
    }

  2. Add a method to evaluate a chain to determine if it scores and process it:

    private void CheckScoringChain(List WaterChain)
    {

    if (WaterChain.Count > 0)
    {
    Vector2 LastPipe = WaterChain[WaterChain.Count - 1];

    if (LastPipe.X == GameBoard.GameBoardWidth - 1)
    {
    if (gameBoard.HasConnector(
    (int)LastPipe.X, (int)LastPipe.Y, "Right"))
    {
    playerScore += DetermineScore(WaterChain.Count);

    foreach (Vector2 ScoringSquare in WaterChain)
    {
    gameBoard.SetSquare((int)ScoringSquare.X,
    (int)ScoringSquare.Y, "Empty");
    }
    }
    }
    }
    }


What just happened?


DetermineScore() accepts the number of squares in a scoring chain and returns a score value for that chain. The number of squares in the chain is divided by 5, and that number is squared. The initial number of squares is added to the result, and the final amount is multiplied by 10.


Score = (((Squares / 5) ^ 2) + Squares) * 10


For example, a minimum scoring chain would be 8 squares (forming a straight line across the board). This would result in 1 squared plus 8 times 10, or 90 points. If a chain had 18 squares the result would be 3 squared plus 18 times 10, or 270 points. This makes longer scoring chains (especially increments of five squares) award much higher scores than a series of shorter chains.

The CheckScoringRow() method makes sure that there are entries in the WaterChain list, and then examines the last piece in the chain and checks to see if it has an X value of 7 (the right-most column on the board). If it does, the HasConnector() method is checked to see if the last pipe has a connector to the right, indicating that it completes a chain across the board.

After updating playerScore for the scoring row, CheckScoringRow() sets all of the pieces in the scoring row to Empty. They will be refilled by a subsequent call to the GenerateNewPieces() method.

Input handling


The player interacts with Flood Control using the mouse. For readability reasons, we will create a helper method that deals with mouse input and call it when appropriate from the Update() method.

Time for action - handling mouse input


  1. Add the HandleMouseInput() helper method to the Game1 class:

    private void HandleMouseInput(MouseState mouseState)
    {

    int x = ((mouseState.X -
    (int)gameBoardDisplayOrigin.X) / GamePiece.PieceWidth);

    int y = ((mouseState.Y -
    (int)gameBoardDisplayOrigin.Y) / GamePiece.PieceHeight);

    if ((x >= 0) && (x (y >= 0) && (y {
    if (mouseState.LeftButton == ButtonState.Pressed)
    {
    gameBoard.RotatePiece(x, y, false);
    timeSinceLastInput = 0.0f;
    }

    if (mouseState.RightButton == ButtonState.Pressed)
    {
    gameBoard.RotatePiece(x, y, true);
    timeSinceLastInput = 0.0f;
    }
    }
    }


What just happened?


The MouseState class reports the X and Y position of the mouse relative to the upper left corner of the window. What we really need to know is what square on the game board the mouse was over.

We calculate this by taking the mouse position and subtracting the gameBoardDisplayOrigin from it and then dividing the remaining number by the size of a game board square.

If the resulting X and Y locations fall within the game board, the left and right mouse buttons are checked. If the left button is pressed, the piece is rotated counterclockwise. The right button rotates the piece clockwise. In either case, the input delay timer is reset to 0.0f since input was just processed.

Letting the player play!


Only one more section to go and you can begin playing Flood Control. We need to code the Update() method to tie together all of the game logic we have created so far.

Time for action - letting the player play


  1. Modify the Update() method of Game1.cs by adding the following before the call to base.Update(gameTime):

    switch (gameState)
    {
    case GameStates.TitleScreen:
    if (Keyboard.GetState().IsKeyDown(Keys.Space))
    {
    gameBoard.ClearBoard();
    gameBoard.GenerateNewPieces(false);
    playerScore = 0;
    gameState = GameStates.Playing;
    }
    break;

    case GameStates.Playing:
    timeSinceLastInput +=
    (float)gameTime.ElapsedGameTime.TotalSeconds;

    if (timeSinceLastInput >= MinTimeSinceLastInput)
    {
    HandleMouseInput(Mouse.GetState());
    }

    gameBoard.ResetWater();

    for (int y = 0; y {
    CheckScoringChain(gameBoard.GetWaterChain(y));
    }

    gameBoard.GenerateNewPieces(true);

    break;
    }


What just happened?


The Update() method performs two different functions, depending on the current gameState value. If the game is in TitleScreen state, Update() examines the keyboard, waiting for the Space bar to be pressed. When it is, Update() clears the gameBoard, generates a new set of pieces, resets the player's score, and changes gameState to Playing.

While in the Playing state, Update() accumulates time in timeSinceLastInput in order to pace the game play properly. If enough time has passed, the HandleMouseInput() method is called to allow the player to rotate game pieces.

Update() then calls ResetWater() to clear the water flags for all pieces on the game board. This is followed by a loop that processes each row, starting at the top and working downward, using CheckScoringChain() and GetWaterChain() to "fill" any pieces that should have water in them and check the results of each row for completed chains.

Finally, GenerateNewPieces() is called with the "true" parameter for dropSquares, which will cause GenerateNewPieces() to fill the empty holes from the squares above, and then generate new pipes to replace the empty squares.

Play the game


You now have all of the components assembled, and can run Flood Control and play!

Summary


You now have a working Flood Control game. In this article we have looked at:

  • Adding content objects to your project and loading them into textures at runtime using an instance of the ContentManager class
  • Dividing the code into classes that represent objects in the game
  • Building a recursive method
  • Use the SpriteBatch.Draw() method to display images
  • Divide the Update() and Draw() code into different units based on the current game state

In the next article, we will spruce up the Flood Control game, adding animation by modifying the parameters of the SpriteBatch.Draw() method and creating text effects in order to make the game visually more appealing.
Cancel Save
0 Likes 2 Comments

Comments

pixeltasim
Great tutorial, read the book and really enjoyed it. It is definitely not for non programmers, but great for programmers who want to understand the concepts of game development.
October 19, 2012 12:36 PM
You must log in to join the conversation.
Don't have a GameDev.net account? Sign up!
Advertisement