Previous

A Card Game with images and buttons

Next

Did you know that you can download pictures of playing cards from the Wikimedia Commons project? Neither did I until I went looking for them.

If we can get a free set of cards images to play with, it would almost be a crime to not use them to make a game.

A solitaire version of Concentration is pretty easy to write. We can write this game using buttons and images like we did for Tchuka Ruma in lesson 15.

This lesson will show you how to do that.

You can find a set of card images here, but you can download a zip file of the cards, and a card back and a blank card by clicking here.

Now that you've downloaded the card images and unpacked the zip file, you'll want to load them into a Tcl/Tk program and see how they look.

The card images in the zip file are named following a pattern:

  1. the first letter is either a h, s, d, or c for hearts, spades, diamonds, or clubs.
  2. The next character is the underscore character,
  3. The underscore is followed by one or two characters for the card rank - a, k, q, j, 10, 9, 8, 7, 6, 5, 4, 3, 2
  4. Last comes the file suffix .gif to let us know that these are Graphic Interchange Format image files.

To load image in a file named s_a.gif (the Ace of spaces) and create an image named s_a, we'd use a command like this:


image create photo s_a -file "s_a.gif"

With the Tchuka Ruma game, there were just 9 images to load, and it wasn't too unpleasant to type in the 9 image commands.

Now we've got 54 images to load (counting a card back and blank card).

Typing 54 image create commands is almost as boring as writing "I will let a computer do the boring stuff" 10,000 times.

The last time we had to do something over and over, we used a for loop. We could use a for loop to count the cards from 2 to 10, but how do we count from j to a?

You might have guessed that this isn't the first time a computer programmer has had this problem.

The solution to this problem is the foreach command. The foreach command loops through a list of things, rather than counting a number of loops.

The foreach command has lots of variations on how it will loop. We'll look at the trickier ones later.

For now, we just need to run through the list of cards, and that's simple.

One way the foreach command works is to loop through a list, one element at a time. We give the foreach command a variable to use for the loops (like the counter variable in for commands), a list of things to loop through, and a body to evaluate on each pass through the loop.

Here's some code that will step through a loop and put up a message box for each word in a sentence. Try typing it into Komodo Edit and see what it does.


foreach listElement {This is a list} {
  tk_messageBox -type ok -text "Looking at element: $listElement"
}

The sentence in that example is a simple list. In Tcl, the list is an important type of variable. We use a list when we want to have collection of values that we want to keep in a particular order. Each word in a list is a separate list element, that we can access or change without touching the rest of the list.

Here's some code that will read 3 image files from the disk and create Tcl/Tk images in a program. The images will be named the same thing as the original file, the Ace of Spades image will be named s_a.gif, the King of Spades image will be named s_k.gif, and so forth.


foreach card {s_a.gif s_k.gif s_q.gif} {
  image create photo -name $card -file $card
}

To load all the cards, we'd need to make a list with every file name in it. That's 54 files. I'd rather type 54 words than 54 lines, but I'd rather type less. The less I type, the fewer errors I mkae.

There is a Tcl/Tk command to look at files on the disk and return what it finds. The command is named glob for reasons that made sense at the time.

The way the glob command works is that you give it a pattern for a file name, and it gives you a list of all the files that match that pattern. (Notice that word - list - this is one of the places where the list data type gets used.)

The rules for the pattern are pretty simple


  
letters and numbers Match exactly these letters and numbers in this order
The pattern a.gif would match a.gif but not b.gif or even ab.gif.
? Match a single character.
The pattern ?.gif would match a.gif, b.gif but not abc.gif
* Match any number of characters.
The pattern *.gif would match a.gif, b.gif, or even imageFile.gif, but not a.jpg
[letters or numbers] Match any of the numbers or characters in the list
The pattern [abc].gif would match a.gif, b.gif, or c.gif but not abc.gif.

To get a list of all the .gif image files in a folder, we could use a line like this:


set listOfImageFiles [glob *.gif]

The star (*) is called a wildcard. It will match any set of letters or numbers.

If we want to loop through those files and make images, we can do it with code like this:


foreach imageFile [glob *.gif] {
  image create photo -name $imageFile -file $imageFile
}

That's a whole lot better than typeing 52 lines of image create commands, and even better than typing 52 file names.

The idea of naming all the images with the same name as the file, including the .gif extension is a bit kludgey, though.

We can do better.

A list is a bunch of words separated by spaces, and sometimes grouped with curly braces if a list element has spaces in it. But sometimes we get a bunch of stuff that we want to treat as a list that's not separated by spaces.

For instance, a file name is two parts - the unique name part and the suffix part, separated by a period. It would be nice to be able to treat those two parts separately.

If we could convert s_a.gif to two words - s_a and gif it would be a list. There's lots of commands for working with lists.

Tcl/Tk has a way to split strings of characters into lists. We call it the split command. We give the split command a string and one or more characters to split on, and it gives us a list that starts a new list element at each location where there was a character to split the string on.

For example, try this:


set newList [split s_a.gif .]
tk_messageBox -type ok "s_a.gif split on the . is: $newList"

Try replacing the "." in the split command with a "_", or "a" and see what you get for a new list.

We don't really want a list though, what we want is the unique name part of the file name.

The first element in the list will be the unique part of the file name, something like s_a.

We can loop through the elements in a list with the foreach command, but that doesn't help us much in this case.

There are other commands to work with lists. One of these is the lindex command. The lindex command returns the list element at a given location.

You use the lindex command by typing the lindex command and providing two arguments:

  1. the list to work with.
  2. the numeric position of the element you want returned.

Note that the first list element is element 0, the second is 1, etc. This isn't what you might expect, but computer counting usually starts at 0 instead of 1.

Here's some code code will split the file name and put the first part of the file name into a variable named card.


set fileName "s_a.gif"
set newList [split $fileName .]
set card [lindex $newList 0]

This gets us closer to a procedure to load all the card images. The last trick is that we'll want to save a list of all the cards.

It's time for another command. This time the command is lappend. The lappend command is like the append we first saw in lesson 10.

As you recall, the append command appends new characters to the end of whatever is stored in a variable. The lappend appends one or more new list elements onto a list that's stored in a variable. If the variable hasn't been used yet both append and lappend will create it.

Syntax:lappend listName element1 element2 element3

listName The name of the variable that contains the list to have data appended to it.
element* Elements to append to the list

The lappend command is the only list command that changes the contents of a list. All the other list commands leave the original list like it was and return a new list.

Try this code to see how the lappend command creates a list.


foreach letter {a b c d e} {
  lappend myList $letter
}
tk_messageBox -type ok -message "My list is $myList"

Here's some sample code for loading bunch of files and making images with the same name as the first part of the file name, and saving all the names in a list.


foreach file [glob *_*.gif] {
  # split the card name (c_8) from the suffix (.gif)
  set card [lindex [split $file .] 0]
    
  # Create an image with the card name, using the file
  # and save it in a list of card images
    
  image create photo $card -file $file
  lappend cardsList $card  
}

Once you've typed that code into Komodo Edit and run it, you've created the card images, but they aren't being displayed anyplace.

We can put each image onto a button to check that the program is working by typing the code below into Komodo Edit. This will make 52 labels in 7 rows and 8 columns The last row will only have 4 cards.


set row 0
set column 0
foreach card $cardsList {
  label .l_$card -image $card
  grid .l_$card -row $row -column $column
  incr column
  if {$column > 7} {
    set column 0
    incr row
  }
}

Here's an old joke: an optimist thinks a glass is half-full while a pessimist thinks it's half empty. An engineer thinks the glass is too big.

If you typed in the last two snippets of code, you noticed that we've got one half-empty row (or half-full, if you're an optimist). Computer programmers are like engineers - we think there's the wrong number of cards to fit evenly in the number of rows we're using.

If we got rid of 4 cards, the cards would fit in 6 even rows. So, lets toss out all the Aces. We can do that by looking at each card as we create the images to see if it's an ace. If it's an ace, we won't load it.

We can check to see if the card is an Ace with the ne operator for the if command, and using the AND (&&) operator to combine 4 tests into one. The code below will check to see if a fileName is one of the Aces.


set fileName "s_9.gif"
if {($fileName ne "c_a.gif") &&
    ($fileName ne "h_a.gif") &&
    ($fileName ne "d_a.gif") &&
    ($fileName ne "s_a.gif")} {
  tk_messageBox -type ok -message "$fileName is not an ace"
}

That's enough commands for the moment, here's a procedure for loading the cards, creating the images, and storing the cards in a list.

As usual, we'll have a global variable that's an array to hold the information we need for this game. We'll name that variable concentration. We'll need list of all the cards. It makes a program easier to read if we use names for our variables that describe what they hold, so we'll store the names of the card images in concentration(cards).


################################################################
# proc loadImages {}-- 
#    Load the card images
# Arguments
#   NONE
#
# Results
#   The global array "concentration" is modified to include a
#   list of card image names
#
proc loadImages {} {
  global concentration
  
  # The card image fileNames are named as S_V.gif where
  #  S is a single letter for suit (Hearts, Diamonds, Spades, Clubs)
  #  V is a 1 or 2 character descriptor of the suit - one of:
  #     a k q j 10 9 8 7 6 5 4 3 2
  #
  # glob returns a list of fileNames that match the pattern - *_*.gif
  #  means all fileNames that have a underbar in the name, and a .gif extension.
  
        
  foreach fileName [glob *_*.gif] {
    # We discard the aces to leave 48 cards because that makes a
    # 6x8 card display.  

    if {($fileName ne "c_a.gif") &&
        ($fileName ne "h_a.gif") &&
        ($fileName ne "d_a.gif") &&
        ($fileName ne "s_a.gif")} {

      # split the card name (c_8) from the suffix (.gif)
      set card [lindex [split $fileName .] 0]

      # Create an image with the card name, using the file
      # and save it in a list of card images: concentration(cards)
  
      image create photo $card -file $fileName 
      lappend concentration(cards) $card
    }

  # Load the images to use for the card back and
  #   for blank cards
  
  foreach fileName {blank.gif back.gif} {
      # split the card name from the suffix (.gif)      
      set card [lindex [split $fileName .] 0]
    
      # Create the image     
      image create photo $card -file $fileName
  }
}  

If we deal the cards out into the rows and columns the way we tested to see if the images got loaded OK, we'd always get the cards in the same order. This might be a fun game once, but it would get boring pretty quickly.

We need to shuffle the cards.

If you are shuffling a real deck of cards, you probably know of several ways -

  1. you hold all the cards in one hand and toss them into the other hand a few at a time.

  2. you hold half the cards in each hand and "riffle" the cards together by lifting the corners of the half-decks and releasing them one card at at time.

  3. you throw all the cards in the air and hope you find them all.

For people, it's easiest to shuffle the cards by doing something with a deck. For a computer, it's easiest to do stuff one card at a time. Remember, the computer is very fast at doing simple things in a loop, but not so good at doing complex stuff with a lot of things.

We could shuffle real cards by taking one card at random from the deck and putting it in the another pile until all the cards are in the second pile.

This is a slow way for people to shuffle cards, but it's a good way for a computer.

For the number guessing games, we made up secret numbers using the rand() function of the expr command. The rand() function returns a fraction between 0 and .999. We can convert that to a number between 0 and N by multiplying the fraction by N, and can convert that to a whole number with the int() function.

To find a random card in the card list, we could use the lindex command and a random position with code like this:


lappend newCardStack [lindex $concentration(cards) [expr int(rand()*48)]]

That finds a random card in the deck (or a random image in the list if you prefer) and puts it onto the new stack of cards (OK, it really appends it to the list of images).

Unlike moving a physical card from one deck to the other, we just made a new card to put in the new list, and didn't remove the original card. Copying a card without destroying the original would be a neat trick in real life (I'd like to shuffle the money in my wallet like that), but it's not what we want to do in this computer program.

What we need to do for a computer is to append the name of the card image in the new list and then delete it from the original list.

Tcl/Tk doesn't exactly have a "delete element in a list" command. What Tcl/Tk has is a "replace an element in the list" command, that lets you replace one or more list elements with new values. If the command doesn't provide enough new values to be put into the list, Tcl/Tk replaces those list elements with nothing. Putting nothing where a list element was deletes the list element.

You give the lreplace command:

  1. a list to modify.
  2. the first position to change.
  3. the last position to change.
  4. 0 or more new elements to put in the new list

Note that the lreplace command doesn't change your original list. This command creates a new list with the new elements you requested.

Here's an example of using the lreplace command.


set oldList {a b c d e f g h i j}

# This is the position of the c
set first 2

# This is the position of the f
set last 5

set newList [lreplace $oldList $first $last 1 2 3]

tk_messageBox -type ok -message "The new list is : $newList"

Try typing that code into Komodo Edit and see what comes out in the message box.

The new list you see should be a b 1 2 3 g h i j. We told the lreplace command to replace the characters between the c and the f (including the c and f) with the numbers 1, 2, and 3. We took out 4 letters (c d e f) and put in 3 numbers (1 2 3).

Here's the syntax for the lreplace command.


Syntax: lreplace list first last ?values?   
lreplace Change the value of a list element and return a new list.
list The Tcl list to modify.
first The first element to be modified.
last The last element to be modified.
values Optional values to be placed in the list between the selected positions.

We have one more problem to solve before we can write a procedure to shuffle the card images. Each time we move a random image from the original list into the new list (taking one card out of the deck and putting it in the pile on the table), there are fewer cards in the deck.

We know how to calculate a random number between 0 and N. But, every time we take a card out of the deck (remove an image from the list) the value of N gets one smaller.

We usually write our for loops to count up from 0 to N, but we can also write a for to count down from N to 0.

We know we've got 48 cards, so we can set the initial value of the loop counter to 48, and count down until there are 0 cards left in the original pile.

We could make a shuffleCards procedure that knows about the global concentration array and which index (cards) holds the list of cards, how many cards there are and stuff like that. This would be a perfectly good procedure for this program.

But we could only use that procedure in this concentration game.

We'll want to randomize the list of cards for any card game we write. We'll even want to randomize lists of other things in other games. Even different card games might have different numbers of cards in the deck (think of writing a pinochle or euchre game).

This is a good procedure to write so that we can use it again in a different program. We call this code reuse. One of the things that makes some programmers better than others is how many reusable procedures they have available to plunk into a new program. Not having to write and test a new procedure saves a lot of time.

We still need to know how many elements are in the list. The way to learn that is another list command. This one is llength. You give llength a list, and it returns the number of list elements.

This sample shows how you might use the llength command.


set myList {1 2 3 4}
set length [llength $myList]
tk_messageBox -type ok -message "There are $length elements in $myList"

That's all the new commands we need to shuffle the deck of cards. We call this procedure randomizeList, rather than shuffleCards, since we can use it to change the order of any list of things.

Take a look at this code. We pass the randomizeList a list of stuff, and it returns a new list, with all the same stuff as the original, but in a different order.


################################################################
# proc randomizeList {}--
#    Change the order of the cards in the list
# Arguments
#   originalList        The list to be shuffled
#   
# Results
#   The concentration(cards) list is changed - no cards will be lost
#   of added, but the order will be random.
#   
proc randomizeList {originalList} {

  # How many cards are we playing with.
  set listLength [llength $originalList]

  # Initialize a new (random) list to be empty
  set newList {}

  # Loop for as many cards as are in the card list at the
  #   start.  We remove one card on each pass through the loop.
  for {set i $listLength} {$i > 0} {incr i -1} {

    # Select a random card from the remaining cards.
    set p1 [expr int(rand() * $i)]
  
    # Put that card onto the new list of cards
    lappend newList [lindex $originalList $p1]
  
    # Remove that card from the card list.
    set originalList [lreplace $originalList $p1 $p1]
  }
   
  # Replace the empty list of cards with the new list that's got all
  # the cards in it.
  return $newList   
}

Ok, that loads the card images and shuffles the deck. We're on the home stretch now.

In order to make a replayable game, we need to have 4 procedures that work together:

I added one more procedure to this list for this game - checkForFinished. This procedure gets called from playerTurn to see if the player has found the last card yet. The code that does the check is very small (a single if statement), but by putting this into a separate procedure it will be easier to change the rules for winning the game if we want to.

Look at the command that's given to the buttons. We call the playerTurn proceudure with the position of this card in the list. The first button relates to the first card, the second button relates to the second card, etc.

The list of cards will not change while we play a game of concentration, so we can identify a card by either the image name (s_q) or it's position in the list (the card at position 5 might be the Queen of Spades).

Here's the complete code for this game. Copy it into Komodo Edit and see how it plays.


################################################################
# proc loadImages {}--
#    Load the card images 
# Arguments
#   NONE
# 
# Results
#   The global array "concentration" is modified to include a 
#   list of card image names
# 
proc loadImages {} {
  global concentration
  
  # The card image fileNames are named as S_V.gif where 
  #  S is a single letter for suit (Hearts, Diamonds, Spades, Clubs)
  #  V is a 1 or 2 character descriptor of the suit - one of:
  #     a k q j 10 9 8 7 6 5 4 3 2
  #
  # glob returns a list of fileNames that match the pattern - *_*.gif 
  #  means all fileNames that have a underbar in the name, and a .gif extension.
  
  
  foreach fileName [glob *_*.gif] {
    # We discard the aces to leave 48 cards because that makes a 
    # 6x8 card display.

    if {($fileName ne "c_a.gif") &&
        ($fileName ne "h_a.gif") &&
	($fileName ne "d_a.gif") &&
	($fileName ne "s_a.gif")} {
    
      # split the card name (c_8) from the suffix (.gif)
      set card [lindex [split $fileName .] 0]
    
      # Create an image with the card name, using the file
      # and save it in a list of card images: concentration(cards)

      image create photo $card -file $fileName
      lappend concentration(cards) $card
    }
  }
  
  # Load the images to use for the card back and 
  #   for blank cards

  foreach fileName {blank.gif back.gif} {
      # split the card name from the suffix (.gif)
      set card [lindex [split $fileName .] 0]
    
      # Create the image
      image create photo $card -file $fileName
  }
}

################################################################
# proc randomizeList {}--
#    Change the order of the cards in the list
# Arguments
#   originalList	The list to be shuffled
# 
# Results
#   The concentration(cards) list is changed - no cards will be lost
#   of added, but the order will be random.
# 
proc randomizeList {originalList} {

  # How many cards are we playing with.
  set listLength [llength $originalList]
  
  # Initialize a new (random) list to be empty
  set newList {}
  
  # Loop for as many cards as are in the card list at the
  #   start.  We remove one card on each pass through the loop.
  for {set i $listLength} {$i > 0} {incr i -1} {

    # Select a random card from the remaining cards.
    set p1 [expr int(rand() * $i)]

    # Put that card onto the new list of cards
    lappend newList [lindex $originalList $p1]

    # Remove that card from the card list.
    set originalList [lreplace $originalList $p1 $p1]
  }
  
  # Replace the empty list of cards with the new list that's got all
  # the cards in it.
  return $newList
}

################################################################
# proc makeGameBoard {cardList}--
#    Create the game board widgets - canvas and labels.
# Arguments
#   cardList    A list of card face images
#   
# Results
#   New GUI widgets are created.
#   
proc makeGameBoard {cardList} {
  label .lscoreLabel -text "Score"
  label .lscore -textvariable concentration(player,score)
  label .lturnLabel -text "Turn"
  label .lturn -textvariable concentration(turn)
  grid .lscoreLabel -row 0 -column 1 -sticky e
  grid .lscore -row 0 -column 2  -sticky w
  grid .lturnLabel -row 0 -column 3  -sticky e
  grid .lturn -row 0 -column 4  -sticky w
  
  set numberOfCards [llength $cardList]
  
  for {set i 0} {$i < $numberOfCards} {incr i} {
    set row [expr ($i / 8) + 1]
    set column [expr $i % 8]
    # The button have the command and image configured in startGame
    button .b_$i 
    grid .b_$i -row $row -column $column 
  }
}

################################################################
# proc startGame {}--
#    Actually start a game running
# Arguments
#   NONE
# 
# Results
#   initializes per-game indices in the global array "concentration"
#   The card list is randomized
#   The GUI is modified.
# 
proc startGame {} {
  global concentration
  set concentration(player,score) 0
  set concentration(turn) 0
  set concentration(selected,rank) {}

  set concentration(cards) [randomizeList $concentration(cards)]

  set numberOfCards [llength $concentration(cards)]
  
  # Puts the card images onto the buttons and set the -command
  # option

  for {set i 0} {$i < $numberOfCards} {incr i} {
    .b_$i configure -command "playerTurn $i" -image back 
  }
}

################################################################
# proc playerTurn {position }--
#    Selects a card for comparison, or checks the current
#    card against a previous selection.
# Arguments
#   position    The position of this card in the list of card images
#
# Results
#     The selection fields of the global array "concentration"
#     are modified.
#     The GUI is modified.
# 
proc playerTurn {position} {
  global concentration
  
  set card [lindex $concentration(cards) $position]
  set tempList [split $card _]
  set rank [lindex $tempList 1]

  # Lower the card button to show the image of this card, instead of
  # the card back.
  #
  # This makes it look like we turned a card from being face down to
  # face up.

  .b_$position configure -image $card
  
  # If concentration(selected,rank) is empty, this is the first
  #   part of a turn.  Mark this card as selected and we're done.
  if {{} eq $concentration(selected,rank)} {
      # Increment the turn counter
    incr concentration(turn)

    set concentration(selected,rank) $rank
    set concentration(selected,card) $card
    set concentration(selected,buttonNum) $position
  } else {
    # If we're here, then this is the second part of a turn.
    # Compare the rank of this card to the previously saved rank.

    # Update the screen *NOW* (to show the card), and pause for one second.
    update idle
    after 1000

    # If the ranks are identical, handle the match condition
    if {$rank eq $concentration(selected,rank)} {
      # Increase the score by one
      incr concentration(player,score)

      # Remove the two cards and their backs from the board
      .b_$position configure -image blank -command {}
      .b_$concentration(selected,buttonNum) configure -image blank -command {}
      
      # Check to see if we've won yet.
      if {[checkForFinished]} {
        endGame
      }
    } else {
      # If we're here, the cards were not a match.
      # Show the backs on the two cards
      .b_$position configure -image back
      .b_$concentration(selected,buttonNum) configure -image back
    }
    
    # Whether or not we had a match, reset the concentration(selected,rank)
    # to an empty string so that the next click will be a select.
    set concentration(selected,rank) {}
  }
}

################################################################
# proc checkForFinished {}--
#    checks to see if the game is won.  Returns true/false
# Arguments
#   
# 
# Results
#   
# 
proc checkForFinished {} {
  global concentration
  if {$concentration(player,score) == 24} {
    return TRUE
  } else {
    return FALSE
  }
}

################################################################
# proc endGame {}--
#    Provide end of game display and ask about a new game
# Arguments
#   NONE
# 
# Results
#   GUI is modified
# 
proc endGame {} {
  global concentration
    
  set numberOfCards [llength $concentration(cards)]

  for {set i 0} {$i < $numberOfCards} {incr i} {
    .b_$i configure -image [lindex $concentration(cards) $i]
  }
  button .bAgain -text "Play Again" -command {
      destroy .bAgain
      destroy .bQuit
      startGame
  }

  grid .bAgain -row 3 -column 4    

  button .bQuit -text "Quit" -command "exit"
  grid .bQuit -row 4 -column 4
}

# Call the one-time procedures and start the game
loadImages
makeGameBoard $concentration(cards)
startGame


Type or copy/paste that code into Komodo Edit and try playing a couple of games.

Notice the update idle command and the after command. We looked at these in lesson 13. The update idle tells Tcl/Tk to update the display right now. The after causes the game to pause for a second so you can see the cards before it turns them over again.

Try changing the endGame procedure to tell you if you've got a new best score. You'll need to add a label or tk_messageBox to the endGame to tell the user what the best score was. You'll need to add a new index to the concentration array to remember what the previous best score was.

Try clicking the same button twice. There's a way to cheat at this game! Try adding another test to playerTurn to prevent people from cheating.


This lesson introduced a lot of new ideas. The big concept is the idea of storing data as a list. The list and the associative array are the two important types of variables in a Tcl/Tk program. We use these types of variables to organize data, not just to store it. As your programs get more complex, organizing the data becomes more important than writing the commands.

The important points in this lesson are:


In the next lesson we'll look at ways to make this game look cooler and prevent people from cheating.

Previous

Next


Copyright 2007 Clif Flynt