Previous

A better looking card game

Next

The concentration game with images on the buttons looks cooler than buttons with a bunch of words like "Queen Hearts" and "Back of Card". But, to make really nifty games, we need the canvas widget we discussed in lesson 16.

Lets see how hard it is to convert the previous concentration game into one that draws the cards on the canvas instead of buttons.

As with converting Tchuka Ruma from a button-based game to a canvas based game, most of our program won't need to be changed.

The procedures to load the images (loadImages) and to shuffle the deck (randomizeList) won't need to be changed at all.

The procedure that makes the board will need to be changed a lot. But even this procedure can keep some parts the same - we still want to use the two labels and buttons at the top, for instance.

Here's the code to make a the game board using a canvas. I calculated the dimensions of the canvas by multiplying the height and width of the cards and adding some extra because we want to put a little space between cards. We'll talk about how to find out the size of an image later in this lesson.

The canvas is gridded with the -columnspan 4 option to tell the grid command that we want the canvas to use columns 1-4. This lets us to put the 4 labels above the canvas in columns 1, 2, 3 and 4. We first looked a the -columnspan option in lesson 8.


################################################################
# proc makeGameBoard {}--
#    Create the game board widgets - canvas and labels.
# Arguments
#   NONE
#
# Results
#   New GUI widgets are created.
#
proc makeGameBoard {} {
  # Create and grid the canvas that will hold the card images
  canvas .game -width 718 -height 724 -bg gray
  grid .game -row 1 -column 1 -columnspan 4

  # Create and grid the labels for turns and score
  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
}

You may have noticed that we didn't display any cards in the makeGameBoard procedure. We'll make all the card images in the startGame procedure.

Before we start putting cards on the canvas, we want to make sure the canvas is empty. The first time we play a game, we know it's empty, but after that, we might not be so sure.

In lesson 16, we used tags to identify the things we drew on the canvas. Tcl/Tk also gives us a few tags for free. For instance, the tag all means everything on a canvas. So, if we want to clean up a canvas before we draw on it, we can do this with a command like this;


.canvasName delete all

When we make games with buttons, we use the grid command. The grid command takes care of arranging the buttons so they all fit nicely on the screen. A canvas needs us to tell it the exact location for things we draw on it.

The grid command makes sure that one widget won't overlap another. We probably don't want to display one button on top of another.

The canvas will happily let us draw one thing on top of another. We frequently want to draw one thing on top of another. For instance, in the Tchuka Ruma game. we drew the bins first, and then drew the beans on top of each bin .

Before we tell the Tcl/Tk canvas to display the image of a card, we need to calculate just where we want the card to go. To do that, we need to know how big the card images are.

We've used the image create command to load the images. Tcl/Tk has several other image commands that we will use eventually.

The two that we can use right now are:

Command Description
image height imageName Returns the height of the image in pixels
image width imageName Returns the width of the image in pixels

Since all the cards are the same size, we only need get the height and width for one card. We can save the height and width to use later when we calculate where to put the cards.

We don't really want the cards to sit right next to each other. They'll look better if there's just a little space between cards.

Here's the code that finds the height and width, and then adds 2 pixels to each dimension.


  # Save the height and width of the cards to make the code easier
  #  to read.

  set height [image height [lindex $concentration(cards) 0]]
  set width [image width  [lindex $concentration(cards) 0]]
    
  # Leave spaces between cards.
  
  incr width 2
  incr height 2

There's one more thing before we can start drawing the cards on the canvas. We need to tell Tcl/Tk how to react when someone mouse-clicks a card.

Buttons always do things when we click them. We define what we want the button to do with the -command option.

In Tchuka Ruma, we used the bind to make a canvas do something when we clicked it, so it would behave like a button.

In this version of concentration, we've only got one canvas, and we want to do something different with each image we create on it.

As you might expect, we can do this.

Tcl/Tk lets us tell any widget to watch for an event and do something when that event happens. That's what the bind command does.

The Tcl/Tk canvas has a command built into it that lets us tell every item that we create on the canvas to watch for events and do something when the event occurs.

In Tchuka Ruma We added a binding to the canvas with code like this:


  # Create and grid a canvas widget

  canvas .c_1 -width 80 -height 118 -background blue
  grid .c_1

  # Tell the canvas named .c_1 to watch for a mouse-button event
  # and create a messageBox when the user clicks this canvas.

  bind .c_1  "tk_messageBox -type ok -message {One}"

We can create an oval on a canvas and add a binding to it with code like this:


  # Create and grid a canvas widget

  canvas .c_2 -width 80 -height 118 -background yellow
  grid .c_2
  
  # Tell the canvas named .c_2 to watch for a mouse-button event
  # when the cursor is over items with the tag "sample".
  # Open a message box when a user clicks an item tagged "sample"

  .c_2 create oval 20 20 60 60 -tag sample -fill red
  .c_2 bind sample  "tk_messageBox -type ok -message {Two}"

Try typing these two examples into Komodo Edit and see how they behave. You'll notice that you can click anyplace on the blue canvas to get a message box with One in it, but you need to click in the red circle to get a message box in the yellow canvas.

Here's the startGame procedure. It begins with the same code we used in the previous game by clearing the indices in the global array concentration. Then it gets the sizes for the cards and starts creating the images on the canvas.

Look closely at the code that creates the images. We only use two coordinates to place an image, rather than the bounding rectangle that we use for ovals and rectangles.

The reason for this is that the size of an image is fixed. It will be as big as it is, no bigger, no smaller.

We only need to tell Tcl/Tk where to put the image, not the size of the image. We can define a location with just two coordinates, an X coordinate and a Y coordinate. A pair of coordinates specifies a location on the canvas, but it doesn't quite define where to put the image. The image covers some area, and you can put the image in a lot of places and still overlap that X/Y coordinate.

Tcl/Tk centers the image around the coordinate we give it. So, if we have an image that's 20 pixels wide and 40 pixels tall and we tell Tcl/Tk to create this image at X location 30 and Y location 50, the image will cover the rectangle that starts at 20, 30 (X Location 30 - 1/2 the width of the image, Y Location 50-1/2 the height of the image) to 40,70 (30 + 10, and 50+20).

We can change this behavior with the -anchor option. We give the -anchor one or more sides of the image to put at the X/Y location. The sides are defined as north, south, east, and west.

For instance if we create the image with an option like -anchor w, Tcl/Tk will put the center of the left (west) edge on that X/Y location.

This procedure uses -anchor nw to tell Tcl/Tk to put the upper left (North West) corner of the image at the coordinates.

Also look at how this procedure calculates where to put the images. We start out 2 pixels down and 2 pixels in from the upper left corner of the canvas, Each time we create an image, we add the width of the image to the X location. That gives us a new X location just to the right of the image we created.

When the X location is more than 8 times the image width, we know we've put up 8 columns of images, and it's time to step to a new row and reset the X location back to 2.

Also notice that we use the canvas bind command to tell each image to watch for a button release event, and call playerTurn with the position of this card in the list when that event happens.


################################################################
# 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)]
  
  # Save the height and width of the cards to make the code easier
  #  to read.
  set height [image height [lindex $concentration(cards) 0]]
  set width [image width  [lindex $concentration(cards) 0]]

  # Leave spaces between cards.

  incr width 2
  incr height 2
  
  # Remove any existing items on the canvas
  .game delete all
  
  # Start in the upper left hand corner
  set x 2
  set y 2
  
  # Step through the list of cards
  
  for {set pos 0} {$pos < [llength $concentration(cards)]} {incr pos} {
    # Place the back-of-a-card image on the board
    # to simulate a card that is face-down.

    .game create image $x $y -image back  -anchor nw -tag card_$pos
    
    # Add a binding on the card back to react 
    #  to a player left-clicking the back.

    .game bind card_$pos <ButtonRelease-1> "playerTurn $pos"
    
    # Step to the next column (the width of a card)
    incr x $width

    # If we've put up 8 columns of cards, reset X to the
    #   far left, and step down one row.
    if {$x >= [expr $width * 8] } {
      set x 2
      incr y $height
    }
  }
}

The playerTurn procedure also needs a few changes.

The last version of the concentration game used each button's configure command to change the image that was being displayed. Any option that we can define when we create a widget can be changed later with the configure command.

We can do the same thing with items on a canvas with the canvas's itemconfigure command.

The syntax for the itemconfigure command is:

canvasName The name of the canvas that has this item on it.
itemconfigure Change the configuration of an item on this canvas.
tagOrId A Tag or other identifier to specify which item or items to configure
-option value Pairs of options and a new value to set. You can change multiple options with a single itemconfigure command.

Here's an example that builds a canvas, creates a rectangle on it, and then changes the color of the rectangle when you click the button.


# Create and grid a canvas widget
canvas .c
grid .c

# Create a red rectangle

.c create rectangle 20 20 100 100 -fill red -tag changeMe

# Create and grid a button that can change the color of 
#  the rectangle on the canvas

button .b -text "change color" -command ".c itemconfigure changeMe -fill blue"
grid .b

Any option that we can define when we create an item on the canvas can be modified later with the itemconfigure command. We can change the colors of things we've drawn, change the width of lines, and even change the image that's displayed when we use the canvas create image command.

The new playerTurn procedure looks like this.

Notice that after a player finds a match, we configure the image to be blank and use the canvas bind command to redefine the action to take when a button up event occurs to be an empty string. Assigning an empty command means to do nothing. That's the same as removing the binding.


################################################################
# 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 deck.
#
# 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]
  .game itemconfigure card_$position -image $card 
  
  set rank [lindex [split $card _] 1]

  # 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,position) $position
    set concentration(selected,card) $card
  } 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.
    
    if {$position == $concentration(selected,position)} {
      return
    }

    # 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
      .game itemconfigure card_$position -image blank 
      .game itemconfigure card_$concentration(selected,position) -image blank
      .game bind card_$position <ButtonRelease-1> ""
      .game bind card_$concentration(selected,position) <ButtonRelease-1> ""

      
      # Check to see if we've won yet.
      if {[checkForFinished]} {
        endGame
      }
    } else {
      # If we're here, the cards were not a match.
      # configure the cards to be back up (turn the cards face down)

       .game itemconfigure card_$position -image back 
       .game itemconfigure card_$concentration(selected,position) -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) {}
  }
}

The last thing to do is modify the endGame procedure.

In the previous version of this game, we used the configure command to make all the buttons show the card images. We can do the same thing with the canvas itemconfigure command.

We'll want to have a set of buttons to ask the player if they want to play again or quit. We can build the buttons just like we would normally, but instead of using the grid command to display them, we can put them directly on the canvas.

Just like we use the create image command to put an image on the canvas, we use the create window command to put a widget on the canvas.

It's hard to see the two buttons when they are just sitting on the canvas. There are a lot of cards and stuff that keep the eye too busy.

This code creates a rectangle under the buttons to will make them stand out. We use a -stipple option with the rectangle to allow the cards under the rectangle to still be seen.

A stipple means that you have an image with lots of empty spaces in it.

Artists have drawn pictures using stipples since almost forever. Drawing a picture with stipples means you just draw little dots, but never lines. This lets an artist who only has one color of ink show different darkness levels in their pictures. For instance, an artist with only black ink would color a section gray by putting lots of tiny black dots in that section. This technique was used very often in the early days of printing when you only had one color of ink to print with and the artist wanted to show gray tones. Even today, if you look at a newspaper picture under a magnifying glass, you'll see it's made up of lots of little dots.

In Tcl/Tk terms, a stipple pattern is a set of pixels where the canvas command will draw dots, and pixels where it won't. The areas where a dot isn't drawn will show whatever is under the rectangle (or oval).

Take a look at the .game create rectangle command in this procedure. It uses several options we haven't used yet.

-stipple
A stipple pattern puts colors on some pixels and leaves other pixels transparent so you can see the canvas and images underneath. The common stipple patterns are gray50, gray25 and gray75.

-fill
Tells Tcl what color to use to fill a rectangle, oval or text item.

-outline
Tells Tcl what color to make the outline of a rectangle or oval.

-width
Tells Tcl how wide to make the outline.

The code for the endGame procedure looks like this:


 proc endGame {}--
#    Provide end of game display and ask about a new game
# Arguments
#   NONE
#
# Results
#   GUI is modified
#
proc endGame {} {
  global concentration

  for {set pos 0} {$pos < [llength $concentration(cards)]} {incr pos} { 
    .game itemconfigure card_$pos -image [lindex $concentration(cards) $pos]    
  }  
     
  # Update the screen *NOW*, and pause for 2 seconds  
  update idle;  
  after 2000    
    
  .game create rectangle 250 250 450 400 -fill blue \  
      -stipple gray50 -width 3 -outline gray
        
  button .again -text "Play Again" -command {  
      destroy .again  
      destroy .quit
      startGame}
        
  button .quit -text "Quit" -command "exit"  
    
  .game create window 350 300 -window .again  
  .game create window 350 350 -window .quit   
}

Here's the complete code for a concentration game.


################################################################
# 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 {}--
#    Create the game board widgets - canvas and labels.
# Arguments
#   NONE
# 
# Results
#   New GUI widgets are created.
# 
proc makeGameBoard {} {
  # Create and grid the canvas that will hold the card images
  canvas .game -width 718 -height 724 -bg gray
  grid .game -row 1 -column 1 -columnspan 4
  
  # Create and grid the labels for turns and score
  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
}

################################################################
# 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)]
  
  # Save the height and width of the cards to make the code easier
  #  to read.
  set height [image height [lindex $concentration(cards) 0]]
  set width [image width  [lindex $concentration(cards) 0]]

  # Leave spaces between cards.

  incr width 2
  incr height 2
  
  # Remove any existing items on the canvas
  .game delete all
  
  # Start in the upper left hand corner
  set x 2
  set y 2
  
  # Step through the list of cards
  
  for {set pos 0} {$pos < [llength $concentration(cards)]} {incr pos} {
    # Place the back-of-a-card image on the board
    # to simulate a card that is face-down.

    .game create image $x $y -image back  -anchor nw -tag card_$pos
    
    # Add a binding on the card back to react 
    #  to a player left-clicking the back.

    .game bind card_$pos <ButtonRelease-1> "playerTurn $pos"
    
    # Step to the next column (the width of a card)
    incr x $width

    # If we've put up 8 columns of cards, reset X to the
    #   far left, and step down one row.
    if {$x >= [expr $width * 8] } {
      set x 2
      incr y $height
    }
  }
}

################################################################
# 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 deck.
#
# 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]
  .game itemconfigure card_$position -image $card 
  
  set rank [lindex [split $card _] 1]

  # 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,position) $position
    set concentration(selected,card) $card
  } 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.
    
    if {$position == $concentration(selected,position)} {
      return
    }

    # 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
      .game itemconfigure card_$position -image blank 
      .game itemconfigure card_$concentration(selected,position) -image blank
      .game bind card_$position <ButtonRelease-1> ""
      .game bind card_$concentration(selected,position) <ButtonRelease-1> ""

      
      # Check to see if we've won yet.
      if {[checkForFinished]} {
        endGame
      }
    } else {
      # If we're here, the cards were not a match.
      # configure the cards to be back up (turn the cards face down)

       .game itemconfigure card_$position -image back 
       .game itemconfigure card_$concentration(selected,position) -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
    
  for {set pos 0} {$pos < [llength $concentration(cards)]} {incr pos} {
    .game itemconfigure card_$pos -image [lindex $concentration(cards) $pos]
  }
    
  # Update the screen *NOW*, and pause for 2 seconds
  update idle;
  after 2000
    
  .game create rectangle 250 250 450 400 -fill blue \
      -stipple gray50 -width 3 -outline gray
    
  button .again -text "Play Again" -command {
      destroy .again
      destroy .quit
      startGame}

  button .quit -text "Quit" -command "exit"
    
  .game create window 350 300 -window .again
  .game create window 350 350 -window .quit
}
loadImages
makeGameBoard
startGame


Type that code (or copy/paste) it into Komodo Edit and see how it plays. It won't seem very much different from the version of concentration we did with the buttons.

Play with the rectangle in the endGame procedure. Try changing the stipple pattern, color, and widths. Try putting one rectangle over another with different stipple patterns and colors. You can create a lot of interesting visual effects

Change 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.


We learned a few new things we can do with Tcl/Tk in this lesson.

The important points are:


That makes a decent game. The next couple of lessons will discuss some ways to make the game look cooler by showing cards moving with simple animations.

Simple animations are a step towards making arcade style games like PacMan or asteroids.



Previous

Next


Copyright 2007 Clif Flynt