Learn VB .NET Through Game Programming [Electronic resources]

Matthew Tagliaferri

نسخه متنی -صفحه : 106/ 37
نمايش فراداده

Developing Lose Your Mind

The third game you’ll develop in this chapter is the double-Mastermind-style game, affectionately named Lose Your Mind. Like the previous game, it’s a four-tile puzzle. Unlike the previous game, however, the game allows repetition in color and/or shapes. The puzzle could theoretically consist of four red squares, for example.

In this game, the user has 10 chances to guess the puzzle. After submitting a guess, a series of black and white pegs display that indicate how close the guess is to the puzzle. A black peg on the top row indicates that one of the shapes is in the correct location. A white peg on the top row indicates that a correct shape exists but isn’t in the correct location. The bottom row of pegs indicate the same information but concerning the color of the tiles. Note that no information is given as to which tile is being represented by which peg. Figure 4-5 shows a game in progress.

Figure 4-5: A rousing game of Lose Your Mind in progress

In referring to Figure 4-5, you can see that the first guess was four squares (were it a color picture, you could further see four red squares, but you’ll have to take my word for it on that one). The indicators to the right of the first guess show one black peg in the bottom row and one in the top row. The top row indicates the accuracy of the shapes of the current guess. The single black peg in the top row tells the player that one of the four shapes is correct and in the correct location. Because the first guess is all squares, the player can infer that one of the four tiles is a square. The bottom row of the indicators indicates color and also contains one black peg. This indicates that one of the colors is correct and in the correct position. Because all four tiles in the first guess are red, the player can infer that there’s one red tile in the solution (he can’t tell which of the four tiles are red at this point, though).

Onto the second guess: Knowing that one tile in the solution is a square and one is red, this player changed three of the four tiles to yellow triangles. The top row of indicators now tells the player that he has only one shape correct and that it’s in the wrong position. At first, the clues appear to tell the player less than the indicators did in the first guess, but he can infer additional information by these two clues. The player now knows that there’s exactly one square in the solution, and that it’s not in the first position. Furthermore, the player knows at this time that there are no triangles in the solution. This leaves only diamonds and circles left for the remaining three positions. As for color, the two white indicators tell the player that one of the colors must be red and one yellow. Because both indicators are white, the solution must have a yellow tile in the first position (he doesn’t know the red position yet—only that it’s not in the first position).

Creating the Interface and Discovering a Problem

The interface of the game involves clicking the currently active row of tiles to change the tiles’ shapes and colors. A left-click changes the shape of the tile; aright-click changes the color. The shapes and colors cycle through a preset sequence, so four clicks bring the tile back to its starting point.

Implementing this cycling functionality is difficult given the current design of the ColoredShape class. You might remember that the tiles are implemented as a MustInherit ancestor class with a child subclass to represent each shape. For the game to implement the changing of one shape into another, the program would have to be able to change a tile from one class to a different class on the fly. This is akin to changing a control from a button to a textbox when it’s clicked, a task that’s not often asked of a .NET program. Objects don’t change their classes on the fly. An object instance is created from a class definition, and it remains an instance of that class definition throughout its lifetime.

The only way to change a square tile into a circle tile when clicking it is to remove the shape tile from the form and replace it with an instance of the circle tile. Although this solution works, it seems an odd way to solve the problem. It requires a fair amount of setup code every time a user clicks a tile to make sure the new tile retains the location and color information of the tile that’s about to be destroyed. In addition, the program would have to set up event handlers so that the new tile could be clicked again, at which point it would be destroyed and replaced with a new class instance.

What’s happened here is that the current ColoredShape class has taken on anew requirement—the need to be able to change shapes on the fly, and the current multi-subclass implementation doesn’t support this. Note that it’s no problem to change the color on the fly—the color of a tile is a simple property that can be changed by an outside user (provided the user chooses one of the four allowable colors). It appears that you’re going to have to do a bit of refactoring to support the shape-toggling requirement.

Creating the New ColoredShape Class

Having already used the ColoredShape class in two other games, the trick to this refactoring is to change as little of the interface of the class as possible so as to not break the existing games. This is another one of the benefits of encapsulation—if you constrain your refactoring to things entirely “under the hood” of the ColoredShape class, you won’t affect code outside of that class.

Let’s re-engineer the ColoredShape class in a simple way. Instead of implementing the shape via subclasses, you instead implement it via a property. Because the property can have only one of four values, you can create a new enumerated data type to hold these values:

Public Enum ShapeType 
stSquare 
stCircle 
stDiamond 
stTriangle 
End Enum 

You don’t have to give explicit integer values to the four possible values in the enumerated type if you don’t want—VB .NET will do that for you. Now that you have a new data type, you can easily create a property of that type:

Private FShape As ShapeType 
Property Shape() As ShapeType 
Get 
Return FShape 
End Get 
Set(ByVal Value As ShapeType) 
FShape = Value 
Invalidate() 
End Set 
End Property 

This is just like many other properties that you’ve seen to this point, but this one has a custom data type (the new enumerated ShapeType) and an Invalidate occurs whenever this property is changed, which forces the current control to repaint itself.

You need to do some things to change the class from an inherited scheme to asingle class; the following sections describe these tasks.

Removing MustInherit

The ColoredShape class will now be created directly, so you must remove MustInherit from the class definition.

Moving the Shape Drawing Code

The shape drawing methods don’t change much—they just move up into the main class. Declare them private so the outside world can’t see them. Refer to Listing 4-3 to see an example of the shape drawing code. The only difference in the new structure is that the four drawing methods have names such as DrawDiamond and DrawSquare instead of all being named Draw and overriding the base class method.

Changing the Draw Method

The original Draw method handled the drawing of the tile and then relied on overriding to draw the shape. The Draw method of the single all-shapes class must decide which of the private Draw methods to call. You do this via a simple Case statement:

Public Overridable Sub Draw(ByVal g As Graphics) 
Dim b As LinearGradientBrush 
Dim iRad As Integer = Width - (Me.Border * 2) 
Dim r As New Rectangle(Me.Border, Me.Border, iRad, iRad) 
b = New LinearGradientBrush(r, Color.White, _ 
Color.DarkGray, LinearGradientMode.Vertical) 
g.FillRectangle(b, r) 
g.DrawRectangle(Pens.White, r) 
If Backwards Then Exit Sub 
Select Case FShape 
Case ShapeType.stCircle 
DrawCircle(g) 
Case ShapeType.stDiamond 
DrawDiamond(g) 
Case ShapeType.stSquare 
DrawSquare(g) 
Case ShapeType.stTriangle 
DrawTriangle(g) 
End Select 
End Sub 

Converting the Shared CreateByIndex Method to a Standard Constructor

Although the CreateByIndex method worked well as a “broker” on the ancestor class that returned one of four subclasses, this structure isn’t as common when creating an instance of a single class. It’s more common to pass in the color and shape integers to a normal constructor:

Public Sub New(ByVal iShape As Integer, ByVal iColor As Integer) 
MyBase.new() 
Select Case iShape 
Case 0 
Me.Shape = ShapeType.stSquare 
Case 1 
Me.Shape = ShapeType.stCircle 
Case 2 
Me.Shape = ShapeType.stDiamond 
Case 3 
Me.Shape = ShapeType.stTriangle 
Case Else 
Throw New Exception("iShape index must be 0-3") 
End Select 
Select Case iColor 
Case 0 
Me.Color = Color.Red 
Case 1 
Me.Color = Color.Yellow 
Case 2 
Me.Color = Color.Blue 
Case 3 
Me.Color = Color.Green 
Case Else 
Throw New Exception("iColor index must be 0-3") 
End Select 
End Sub 

Note that this change definitely breaks the class interface to the outside world. In the subclassing version of ColoredShape, a tile instance was created using this syntax:

Dim oShape as ColoredShape 
oShape = ColoredShape.CreateByIndex(i, j) 

The syntax for creating a tile now looks like the following:

Dim oShape as ColoredShape 
oShape = New ColoredShape(i, i) 

Make sure to take advantage of the design-time errors that Visual Studio gives you as you’re refactoring code. The development environment will tell you all the places you’ve used the CreateByIndex method as soon as you remove it from your class. You can then zero into these parts of the project and replace this call with the new syntax.

Creating a New ShapeWord Property

The old ShapeWord was a MustOverride property that returned a string. The string returned was defined in the subclasses. The new ShapeWord implementation returns one of four strings based on the current shape using a simple Case statement.

Using a Different Equals Method

To determine if two tiles were “equal” (the same shape and same color), the original version of the ColoredShape compared the class of the two tiles. Tiles of different subclasses didn’t have the same shape. In the new version of the ColoredShape, all tiles are of the same class, so the Equals method now compares the Shape property of the two tiles along with the Color property.

Creating the ToggleShape and ToggleColor Methods

These two new public methods represent new functionality required by the Lose Your Mind game. They rotate the shape or the color of the tile in a predetermined pattern so that a user clicking a tile can change the shape or color depending on which mouse button is clicked. The code that follows is for ToggleShape, and the color toggling method is similar:

Public Sub ToggleShape() 
Select Case Shape 
Case ShapeType.stCircle 
Shape = ShapeType.stDiamond 
Case ShapeType.stDiamond 
Shape = ShapeType.stSquare 
Case ShapeType.stSquare 
Shape = ShapeType.stTriangle 
Case ShapeType.stTriangle 
Shape = ShapeType.stCircle 
End Select 
End Sub 

Creating a CopyFrom Method

This method loads the color and shape of a passed-in tile to the current tile. The Lose Your Mind game uses it when adding a new row onto the board after the user makes a guess so that the four new tiles have the same color/shape as the previous guess:

Public Sub CopyFrom(ByVal c As ColoredShape) 
Me.Color = c.Color 
Me.Shape = c.Shape 
End Sub 

These relatively small changes and few new features have given you the functionality you require—the ability for a tile to change its shape on the fly. Furthermore, you made only one breaking change to the class interface so that almost all of the code in the prior two games remains functional, save for an almost trivial fix in the line that created the tiles.

This isn’t the only way you can change the class to support the on-the-fly changing of a tile’s shape. In fact, classes such as this—where different functionality is supported through a series of Select Case statements within the class—aren’t always considered the best design. Classes designed in this way can be hard to modify or expand upon later. Suppose you want to add new shapes, for example. You’d have to dig through the class looking for all the Case statements that split out the different functionality for each shape and add to it. Although this is a legitimate concern that affects possible future expandability of your game classes, you have to weigh that against using simpler code in the short term. Also, if you have no current plans to expand the class further, this simpler design is adequate for now. If the need does arise to expand the Tile class further, you can refactor the code into a more easily scalable design at that point.

Tip

If you’re interested in learning about a more scalable design without using inheritance, look into the Bridge pattern discussed in Design Patterns Explained: A New Perspective on Object-Oriented Design by Alan Shalloway and James R. Trott (Addison-Wesley, 2001).

Implementing Lose Your Mind

With the new functionality of the tile class completed, you have three additional work classes to create in order to complete the Lose Your Mind game. The first of these is TileCollection, and its purpose is to store four tiles. This class stores the puzzle that the player is trying to guess. Listing 4-20 shows the public interface for this class.

Listing 4.20: The TileCollection Class That Holds Four ColoredShape Objects

Public Class TileCollection 
Inherits System.Collections.CollectionBase 
Public Sub Add(ByVal o As ColoredShape) 
Public ReadOnly Property Item _ 
(ByVal iIndex As Integer) As ColoredShape 
Public Sub Remove(ByVal o As ColoredShape) 
Public Function Clone() As TileCollection 
Public Overrides Function ToString() As String 
End Class 

This rather small class inherits from a .NET Framework class named CollectionBase. This class has an ArrayList protected within it, meaning your inherited classes can add and remove items to it, but the outside world can’t access it directly. Inheriting from CollectionBase allows you to create type-safe collection classes for your programs.

The Add method on this class takes a ColoredShape parameter, meaning that you couldn’t add an instance of any other class to this collection. Likewise, the Item property returns a ColoredShape within it, meaning that this collection class can’t hold instances of other classes. When using an ArrayList directly, as you did in some of the prior applications, the programmer could inadvertently (or even intentionally) add instances of different classes to a single ArrayList, which could make for buggy (or, at the least, confusing) code.

The second class required to finish the game is TileCollectionGuess, which inherits from TileCollection. This class not only stores four tiles but also provides support for deciding how close this group of four tiles is to the puzzle that the player is trying to determine. Listing 4-21 shows the public interface for this class.

Listing 4.21: TileCollectionGuess, Inherits Off of TileCollection

Public Class TileCollectionGuess 
Inherits TileCollection 
Public Sub CheckAgainst(ByVal oSolution As TileCollection) 
ReadOnly Property NumShapeCorrect() As Integer 
ReadOnly Property NumShapeWrongSpot() As Integer 
ReadOnly Property NumColorCorrect() As Integer 
ReadOnly Property NumColorWrongSpot() As Integer 
Public Overrides Function ToString() As String 
Public Function Wins() As Boolean 
End Class 

The workhorse of this class is the CheckAgainst method. This method counts the number of shapes and colors both in correct and incorrect spots in this tile set against a passed-in solution. The function Wins returns True if all the colors and shapes are in the correct spots.

The last worker class, GuessHintRenderer, is a control to render the black and white pegs. This class has almost no public implementation at all:

Public Class GuessHintRenderer 
Inherits Control 
Public Sub New(ByVal oTC As TileCollectionGuess) 
End Class 

The only public method is a constructor that takes a TileCollectionGuess as its parameter, which it uses to determine how many black and white indicator pegs to draw. The drawing code uses standard GDI+ calls to render black and white circles on the control surface.

With all the worker classes completed, all that remains is putting them together on a form to create the game. The method StartGame, shown in Listing 4-22, sets up the game controls.

Listing 4.22: The StartGame Method

Private Sub StartGame() 
Dim oRand As New Random 
Dim oShape As ColoredShape 
Dim i, iTop As Integer 
DeleteOldGameControls() 
FGuesses = New ArrayList 
cbGuess.Visible = True 
FSolution = New LoseYourMind.TileCollection 
i = 0 
Do 
oShape = New ColoredShape(oRand.Next(0, 4), oRand.Next(0, 4)) 
oShape.Backwards = True 
oShape.Width = TILESIZE 
oShape.Left = 32 + (i * oShape.Width) 
FSolution.Add(oShape) 
pnTop.Controls.Add(oShape) 
i += 1 
Loop Until FSolution.Count = NUMTILES 
iTop = HeightFromTurnNumber(0) 
cbGuess.Location = New Point((TILESIZE * NUMTILES) + 40, iTop + 8) 
Call SetupGuess() 
End Sub 

This procedure executes whenever a new game begins. The first thing called is a procedure named DeleteOldGameControls, which simply loops through all the controls on the form and removes any old tiles that might exist from a prior game. Then, an ArrayList named FGuesses initializes (which holds the guesses the player makes), as well as a variable named FSolution (which is an instance of the class TileCollection). Four ColoredShape instances (of random shape and color) are then created and added to both FSolution and to the form (in truth, they’re added to a Panel named pnTop, which is a member of the form). These four tiles represent the puzzle that the user is trying to guess. Finally, a procedure named SetupGuess is called, which is shown in Listing 4-23.

Listing 4.23: The SetupGuess Method, Called Whenever a New Turn Begins

Private Sub SetupGuess() 
Dim oShape As ColoredShape 
Dim oTC As New LoseYourMind.TileCollectionGuess 
Dim o As Control 
Dim i, iTop As Integer 
'create 4 tiles for guessing, put in a guess object. 
'remove clicking ability on all shapes 
For Each o In Me.Controls 
If TypeOf o Is ColoredShape Then 
RemoveHandler o.MouseDown, AddressOf ShapeMouseDown 
End If 
Next 
FCurrentGuess = New LoseYourMind.TileCollectionGuess 
iTop = HeightFromTurnNumber(FGuesses.Count) 
Do 
oShape = New ColoredShape(i, i) 
With oShape 
.Location = New Point(32 + (i * TILESIZE), iTop) 
.Width = TILESIZE 
.Backwards = False 
'copy from guess before 
If FGuesses.Count > 0 Then 
oTC = FGuesses.Item(FGuesses.Count - 1) 
.CopyFrom(oTC.Item(i))          'copies shape and color 
End If 
AddHandler .MouseDown, AddressOf ShapeMouseDown 
End With 
Me.Controls.Add(oShape) 
FCurrentGuess.Add(oShape) 
i += 1 
Loop Until i >= NUMTILES 
End Sub 

The purpose of this method is to create four clickable tiles on the form and add these tiles to a TileCollectionGuess class so that they can be compared against the puzzle when the user hits the Guess button. Each tile is created as a copy of the tile immediately below it (from the guess before, unless of course this is the first guess in the game). Also, each of the four tiles has an event handler named ShapeMouseDown added to it (shown in Listing 4-24) so that it can respond when the player clicks it. (Actually, at the top of this method, all previous tiles on the form don’t contain this same handler so that previous guesses no longer respond to mouse clicks.)

Listing 4.24: The ShapeMouseDown Event

Private Sub ShapeMouseDown(ByVal sender As _ 
System.Object, ByVal e As _ 
System.Windows.Forms.MouseEventArgs) 
Dim oShape As ColoredShape = sender 
If ((e.Button And MouseButtons.Left) = MouseButtons.Left) Then 
oShape.ToggleShape() 
oWav.Play("die1", 100) 
End If 
If ((e.Button And MouseButtons.Right) = MouseButtons.Right) Then 
oShape.ToggleColor() 
oWav.Play("die1", 100) 
End If 
End Sub 

The event handler calls the ToggleShape or ToggleColor method on the tile that’s clicked, which changes the shape or color of the tile in a repeating pattern. If you’ll recall, the need to change the shape of a tile on the fly was the primary reason that you needed to rework the ColoredShape class from an inherited scheme into a single class scheme.

The final major piece of code to implement is the code that runs when a user clicks the Guess button. This code, shown in Listing 4-25, renders the white and black pegs and determines if the player has won or lost the game.

Listing 4.25: Guess Button Event Handler

Private Sub cbGuess_Click(ByVal sender As System.Object, _ 
ByVal e As System.EventArgs) Handles cbGuess.Click 
Dim oGH As LoseYourMind.GuessHintRenderer 
Dim cMsg As String 
FGuesses.Add(FCurrentGuess) 
FCurrentGuess.CheckAgainst(FSolution) 
oGH = New LoseYourMind.GuessHintRenderer(FCurrentGuess) 
oGH.Location = cbGuess.Location 
oGH.Size = New Size(54, 32) 
Me.Controls.Add(oGH) 
If FCurrentGuess.Wins Then 
oWav.Play("ovation", 100) 
ShowSolution() 
cMsg = "Winnah, Winnah, Chicken Dinnah!" & Environment.NewLine 
cMsg &= "Play again?" 
If MsgBox(cMsg, MsgBoxStyle.Question Or _ 
MsgBoxStyle.YesNo, "You win") = MsgBoxResult.Yes Then 
Call StartGame() 
Exit Sub 
Else 
Me.Close() 
End If 
Else 
If FGuesses.Count = 10 Then 
oWav.Play("ouch", 100) 
ShowSolution() 
cMsg = "You Lose!" & Environment.NewLine 
cMsg &= "Play again?" 
If MsgBox(cMsg, MsgBoxStyle.Question Or _ 
MsgBoxStyle.YesNo, "You lose") = MsgBoxResult.Yes Then 
Call StartGame() 
Exit Sub 
Else 
Me.Close() 
End If 
Else 
oWav.Play("8ping", 100) 
End If 
cbGuess.Top -= TILESIZE 
Call SetupGuess() 
End If 
End Sub 

The guess code creates an instance of GuessHintRenderer and adds it to the form so that the white and black pegs can be drawn. This object takes as its parameter the current guess that the player has just made, currently stored in the variable FCurrentGuess. If the player has indeed won the game, he is told so and asked if he wants to play again. The game action is performed if the player has lost the game, which happens if he hasn’t correctly guessed the solution in 10 tries.