A Match-Making Game in Visual Basic
Code download available at:AdvancedBasics0510.exe (282 KB)
Saving and Loading Settings
Making Random Choices
Playing the Game
Drawing the Actual Images
Fit and Finish Work
My four-year-old son has decided that he wants to be like his dad when he grows up. He is planning to work in my office, and write computer programs just like I do. But there is one problem—he thinks I write games. My attempts to explain the complex content publishing systems I work on didn't get very far. It seems that I will have to either get a job in the Xbox® group at Microsoft, or start developing games on the side, so I decided to write a simple game for this column to illustrate some interesting programming concepts. As I looked around at some of my son's favorite games I noticed that he enjoyed playing matching games, so I fired up Visual Basic® and began to write one (see Figure 1).
Figure 1** Cute Matching Game **
It was a great excuse to use GDI+, learn a bit about random number generation, and entertain friends and family. I'll now illustrate the interesting areas of the code and discuss a few very important features that I implemented into the program.
The application is called MatchMaker and, although there is an options window where the user can pick a set of images to use and select other settings, by default it starts up with the most recently used settings. You install the game with a default configuration (with images or sounds that you install along with the program) and then the user can customize the game later. You'll find that game play is straightforward:
- Click on New Game to start with all the cards flipped over.
- Click on one card at a time, trying to make matches.
- If you have one card flipped over and you click on a second card that is a match both cards stay visible for the rest of that game, ignoring any additional clicks.
- If the two cards do not match, then they flash visible for a few seconds before both flip back over and it is time for you to pick another pair of cards.
- Once all cards have been matched, the game is over and the time elapsed is displayed on the game's main form.
- At any time, clicking New Game will reset the playing area and start the game at the beginning again.
- Clicking on the Options button will let you pick a new folder full of images, change the size of the game board, or change the match/no match sound effects. Note that changing any of the Options will reset your game in progress.
As games go, this is a very simple program, but it has enough complexity that I will need to cover it section by section. To start, let's look at the use of both user-level and application-level settings and the creation of an options dialog box.
Saving and Loading Settings
Earlier I mentioned that you would probably want to ship this game with a set of images and sounds already configured for the user, which is easily handled in an App.config file, so I added one to my project and set up the basic "appSettings" list of key/value pairs (see Figure 2).
Figure 2 The App.config File
<?xml version="1.0" encoding="utf-8" ?> <configuration> <appSettings> <!-- default game settings --> <add key="ImagesX" value="3" /> <add key="ImagesY" value="4" /> <add key="SourceImages" value="..\SourceImages" /> <add key="CardBackTile" value="%windir%\River Sumida.bmp" /> <add key="MatchSound" value="%windir%\Media\ding.wav" /> <add key="NoMatchSound" value="%windir%\Media\chord.wav" /> </appSettings> </configuration>
It seemed likely that some of the built-in system sounds and images would end up being referenced in this configuration file, so I decided to allow environment variables (such as %windir% or %userprofile%) to be used in these settings. Before using any of these strings in my application, I obtain the proper paths by calling System.Environment.ExpandEnvironmentVariables.
Now, if I planned to use only these settings to control my application, I could just load them up when the main form loads, but instead I want to use them only as defaults if there are no user-defined settings available. I won't go into all the details of how I load and persist user settings, since I covered that information in the April 2005 installment of Advanced Basics, but I did have to customize a bit of that code to support the loading of default settings from the application configuration file. When writing code to load user-defined settings from disk I handle the possibility that there is no settings file by creating a new settings object populated with the values from the application configuration file (see Figure 3).
Figure 3 Loading User Settings or Using the Defaults
Public Shared Function LoadSettings() As UserSettings Dim loadedSettings As UserSettings = Nothing Dim xs As New XmlSerializer(GetType(UserSettings)) If File.Exists(SettingsPath) Then Dim sr As New IO.StreamReader(SettingsPath) loadedSettings = CType(xs.Deserialize(sr), UserSettings) sr.Close() End If If loadedSettings Is Nothing Then 'Load defaults loadedSettings = New UserSettings With loadedSettings Dim ar As New System.Configuration.AppSettingsReader .ImagesX = DirectCast(ar.GetValue("ImagesX", _ GetType(Integer)), Integer) .ImagesY = DirectCast(ar.GetValue("ImagesY", _ GetType(Integer)), Integer) .SourceImages = DirectCast(ar.GetValue("SourceImages", _ GetType(String)), String) .CardBackImage = DirectCast(ar.GetValue("CardBackTile", _ GetType(String)), String) .MatchSound = DirectCast(ar.GetValue("MatchSound", _ GetType(String)), String) .NoMatchSound = DirectCast(ar.GetValue("NoMatchSound", _ GetType(String)), String) End With End If 'make sure the grid size values are correct With loadedSettings If .ImagesX <= 0 Then .ImagesX = 1 If .ImagesY <= 0 Then .ImagesY = 1 If Not ((.ImagesX * .ImagesY) \ 2) * 2 = _ (.ImagesX * .ImagesY) Then 'ImagesX * ImagesY must equal an even # If .ImagesX > 1 Then .ImagesX -= 1 ElseIf .ImagesY > 1 Then .ImagesY -= 1 End If End If End With Return loadedSettings End Function
This means that until the user decides to click on the Options button and make some changes, the defaults will be used every time. If the user does decide to customize his settings, he will be shown the Options dialog, which is displayed modally and allows the user to browse for the appropriate folder or file for the four main settings, as you see in Figure 4.
Figure 4** Choose the Settings **
Each of the first four settings uses a little custom control called the BrowseTextBox, which I created for just this purpose. It combines the Browse button with the textbox control and will create and configure the Open/Save or Folder Browse dialogs as necessary to allow the user to find a file name or directory. The code for this custom control is included in the download for this column along with everything else you need.
If the user clicks OK, then all of the values from this dialog are copied over the current settings, the settings are saved into a user settings file, images are reloaded from the newly specified path, and any game in progress is reset. In the mainline of my application I don't have to worry about whether or not the user has changed any settings, I just call LoadSettings (shown in the code in Figure 3), and I will get the correct values either way. Once I have a path for images, either at start up or after a change in settings, I loop through all of the .bmp, .gif, .jpeg, and .png files in that path and add every valid image that I can load into a collection of Images.
So, through the settings, I have all the information needed to start a game; I found all my images and I know the size of the grid that the game will be played on. The next step is to randomly determine which pictures should be included and to place them randomly on the playing surface.
Making Random Choices
Although Visual Basic .NET includes its own Random number generator (Rnd) and the Microsoft® .NET Framework provides the System.Random class, I prefer to use another random number generator. Within the Cryptographic classes, which make intensive use of random values, there is an RNGCryptoServiceProvider class that can be used to create random numbers within any range you need, without producing any inadvertent patterns that your users might detect while playing the game. To make this class easier to use from the rest of my code, I wrapped the necessary code up into a function that accepts an upper-bound and then returns an integer between 0 and that bound:
Dim realRandom As New RNGCryptoServiceProvider Private Function GetRndValue(ByVal range As Integer) As Integer Dim numb(3) As Byte realRandom.GetBytes(numb) Return Math.Abs(BitConverter.ToInt32(numb, 0) Mod range) End Function
The RNGCryptoServiceProvider's GetBytes method takes an array of bytes and fills all of those bytes with random values.
Using the GetRndValue function, I was able to write the most critical aspect of the game, the random choosing of images to place on the board for a game. First, given all the available images (lets call that number n), I have to pick enough images out of that list (randomly) to fill half the spots on the game area (which would be x * y / 2). For each pick I get a random number between 1 and n and then increment that value by 1 repeatedly until I find a value in the full image list that has not yet been used. In the code in Figure 5, imagesToPick is equal to x * y / 2, and imageList.Count-1 is the number of images available.
Figure 5 Choose Images Randomly
For i As Integer = 0 To imagesToPick - 1 range = imageList.Count - i pick = GetRndValue(range) ' pick a random index Dim found As Boolean = False Do ' if that index has already been used, move to the next If orderedImages.Contains(imageList(pick)) Then pick += 1 If pick >= imageList.Count Then pick = 0 ' otherwise use it Else orderedImages.Add(imageList(pick)) found = True End If Loop Until found Next
Once I have my smaller subset of images, I need to put two of each of these images onto the game board. The board is a grid, but for my purposes it is simplest to represent it as a linear array with x * y elements, so I just need to fill up that array with two copies of each of my chosen images:
Dim finalList As ArrayList = CType(orderedImages.Clone(), ArrayList) For i As Integer = 0 To finalList.Count - 1 Dim newIndex As Integer = GetRndValue(finalList.Count) Dim tmp As Object = finalList(i) finalList(i) = list(newIndex) finalList(newIndex) = tmp Next
I simply create a copy of the orderedImages list and then swap each element in the list with a random location in the list, thereby creating a randomly ordered list.
Playing the Game
Given the filled array containing the images for every spot on the game board, I have everything I need to start an actual game. I created a separate control to represent the game board itself, and it handles all the user's clicks, remembers their first and second picks in a turn, and raises events to tell the main form when a match is made, when any card is chosen, and when all the matches have been found. The code within this control, other than for drawing the images themselves, is all about tracking the current state of the game, which includes the current picks and the list of all matches that have already been found.
The control is hardcoded to allow the user to pick only two cards at a time, and since it already has the full list of images to handle the drawing, it is able to compare the two chosen items and determine if the user has made a match. Matches are stored into an integer collection (two entries for each match) that is then used in the drawing code to determine if an image or just the standard "card back" should be drawn.
In the main form, if a match is chosen or if the game is over, a message is displayed and a sound is played, but if the user picks two cards that do not make a match then a bit more complicated response is needed. To make the game work, the user needs to see the two non-matching cards they picked for long enough to have a chance to remember them, and then they both need to be flipped back over. I decided to use a timer for this purpose, currently hardcoded to a three-second interval. I enable the timer and then call the game surface's FlipCardsBackOverroutine when the three seconds are up (see Figure 6).
Figure 6 Dealing with Cards that Don't Match
Private Sub gameArea_CardSelected(ByVal sender As Object, _ ByVal e As System.EventArgs) Handles gameArea.CardSelected infoMessage.Text = String.Empty If gameArea.FirstSelection > -1 AndAlso _ gameArea.SecondSelection > -1 Then flipBackTimer.Enabled = True infoMessage.Text = "Not A Match!" Me.gameArea.Cursor = Cursors.WaitCursor NumberOfAttempts += 1 matchAttempts.Text = NumberOfAttempts Dim noMatchSound As String = _ Environment.ExpandEnvironmentVariables( _ m_Settings.NoMatchSound) SoundUtils.PlaySound(noMatchSound, _ IntPtr.Zero, SoundUtils.SoundFlags.SND_ASYNC) End If End Sub Private Sub flipBackTimer_Tick(ByVal sender As Object, _ ByVal e As System.EventArgs) Handles flipBackTimer.Tick flipBackTimer.Enabled = False gameArea.FlipCardsBackOver() Me.gameArea.Cursor = Cursors.Default infoMessage.Text = String.Empty End Sub
During the time that the two cards are visible, the game surface ignores the user's clicks (because it still has his last two choices stored into its properties) but the form is still fully functional and the user could click on the New Game or Options buttons. If a player is particularly impatient, that three-second interval might make him wonder when he could make his next move, so I change the cursor to an hourglass in order to make it a bit clearer that he must wait for a moment.
Drawing the Actual Images
Despite the fact that the game is all about showing images, doing so is actually the simplest functionality to implement. Whenever the game surface needs to be painted, all that is required is a loop through the array of images and a check against the list of matches. If a location in the list is in the match collection or if it is one of the user's current guesses (one or two of which can be active at any time) then draw the image, otherwise draw a standard image as a card back (see Figure 7).
Figure 7 Drawing the Cards
Protected Overrides Sub OnPaint(ByVal e As PaintEventArgs) e.Graphics.InterpolationMode = Drawing2D.InterpolationMode.High If (Not m_CardBack Is Nothing AndAlso Not m_Images Is Nothing _ AndAlso m_Images.GetLength(0) > 0) Then areas.Clear() Dim imageWidth, imageHeight As Integer imageWidth = (Me.Width - _ (m_Spacing * (m_ImagesAcross + 1))) \ m_ImagesAcross imageHeight = (Me.Height - _ (m_Spacing * (m_ImagesDown + 1))) \ m_ImagesDown Dim currentX, currentY As Integer For row As Integer = 0 To Me.m_ImagesDown - 1 currentY = (row * (m_Spacing + imageHeight)) + m_Spacing For col As Integer = 0 To Me.m_ImagesAcross - 1 currentX = (col * (m_Spacing + imageWidth)) + m_Spacing Dim i As Integer = (row * m_ImagesAcross) + (col) If i <= m_Images.GetLength(0) Then Dim imageRect As Rectangle If Me.FirstSelection = i OrElse _ Me.SecondSelection = i OrElse _ m_Matches.Contains(i) Then 'draw real image Dim realImage As Image = Me.m_Images(i) imageRect = ResizeImage(realImage, _ imageHeight, imageWidth) imageRect.Offset(currentX, currentY) e.Graphics.DrawImage(realImage, imageRect) Else 'draw card back imageRect = New Rectangle(currentX, _ currentY, imageWidth, imageHeight) e.Graphics.DrawImage(m_CardBack, imageRect) End If 'draw a border around whatever image I drew Dim myNewPen As Pen = New Pen(Color.Black) myNewPen.Width = 1 e.Graphics.DrawRectangle(myNewPen, imageRect) myNewPen.Dispose() 'add the image area into a collection that is used 'to do a 'hit test' when the user clicks game surface Dim ca As New ClickArea ca.area = New Region(imageRect) ca.index = i areas.Add(ca) End If Next Next End If End Sub
If the real image needs to be drawn, then there is a bit of additional code, ResizeImage, to resize it to fit into the area available while still maintaining its proportions, which you'll also see in the download for this column.
Since the OnPaint code knows to display images if they are one of the user's guesses or if they have been matched already, the game surface just needs to be invalidated whenever a match is made or the user picks a card.
Fit and Finish Work
I did not have much time to make this game look polished instead of just functional, but I did have to add some type of audio support. With that in mind, I added support for simple sounds, playing a different sound depending on whether the user makes a match or not. I decided to limit my sound support to .wav files, instead of trying to support more complex sound files such as .wma. This choice meant that I could use the PlaySound API, instead of trying to work with something like Managed DirectX®, simplifying the code and the system requirements for the user (see the code in Figure 8).
Figure 8 Adding Sound Support
Public Class SoundUtils <DllImport("winmm.dll")> _ Public Shared Function PlaySound(ByVal szSound As String, _ ByVal hModule As IntPtr, ByVal flags As SoundFlags) As Integer End Function <Flags()> _ Public Enum SoundFlags As Integer SND_SYNC = &H0 ' play synchronously (default) SND_ASYNC = &H1 ' play asynchronously SND_NODEFAULT = &H2 ' silence (default) if sound not found SND_MEMORY = &H4 ' pszSound points to a memory file SND_LOOP = &H8 ' loop the sound until next sndPlaySound SND_NOSTOP = &H10 ' don't stop any currently playing sound SND_NOWAIT = &H2000 ' don't wait if the driver is busy SND_ALIAS = &H10000 ' name is a registry alias SND_ALIAS_ID = &H110000' alias is a predefined id SND_FILENAME = &H20000 ' name is file name SND_RESOURCE = &H40004 ' name is resource name or atom End Enum End Class
This game could certainly use a few additional features, such as a high-score list (for fastest-time in this case), but it does what I wanted—provides a working example of a match-making game with all the code available to you in Visual Basic .NET. Check out the Coding 4 Fun site on MSDN® for more game and graphic-related programming ideas!
Send your questions and comments to firstname.lastname@example.org.
Duncan Mackenzie is a developer for MSDN and the author of the Coding 4 Fun column on MSDN online. He can be reached through his personal site at www.duncanmackenzie.net.