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.