4.5 Memory game

To illustrate user interactions with the mouse, we present in this section a memory color game. This is a board game where a collection of cards are presented to the user with a common neutral color, each card has its own color, hidden at game start-up. The user must find the cards sharing the same color. When the user clicks on a card, its color is revealed, the card with the matching color must be found. When a pair of cards are found, these cards are not playable anymore; if not, the cards’ color are hidden again.

Two morphs are used in the design of the game: a kind of SystemWindow and a kind of PluggableButtonMorph. The complete source is presented in the appendix Memory Game v1 of the book. We will not present every part of the code design, but we will focus on the illustrative ones in regard of the topic of this chapter.

The game is started with

MemoryGameWindow new openInWorld
ch04-memoryGame

Figure 4.12: Memory color game

The card

What are the attributes we want a card to have? We need a card to have a specific card color and a status flag to inform if we are done with the card.

The card is a morph able to paint itself with a color. It reacts to user click to flip itself between its own card color and the common neutral color. What we want is a kind of button:

PluggableButtonMorph subclass: #MemoryCard
   instanceVariableNames: 'cardColor done'
   classVariableNames: ''
   poolDictionaries: ''
   category: 'MemoryGameV1'

the default color, common to all the card is white

defaultColor
^ Color white

it knows to be flipped when its cardColor is used as the color of the button

isFlipped
^ color = cardColor 

and the card can flip between this common color and its own cardColor

flip
color := self isFlipped ifTrue: [self defaultColor] ifFalse: [cardColor].
self redrawNeeded

color is used to paint the button; when adjusting it, we send the message #redrawNeeded to the button to force its redraw.

The board

What are the attributes of the game board? It knows about the playground set with a specific size where the cards are presented to the user. It communicates messages through its status bar and it knows if the user is playing or not.

The game is presented in a window with all these attributes:

SystemWindow subclass: #MemoryGameWindow
   instanceVariableNames: 'size cards tupleSize statusBar playground playing'
   classVariableNames: ''
   poolDictionaries: ''
   category: 'MemoryGameV1'

The board presents a toolbar, its playground and status bar in the window column layoutMoprh

initialize
super initialize.
size := 4 @ 3.
tupleSize := 2.
playing := true.
playground := LayoutMorph newColumn.
self installToolbar.
self addMorph: playground.
self installCards.
self installStatusBar

Indeed, by default a SystemWindow comes with a layout, set as a column. The playground is also a column layout, the cards are arranged into in several rows.

Installing the game

Cuis-Smalltalk does not come with any notion of toolbar, but it is fairly easy to create one with a row layout and buttons:

installToolbar
| toolbar button |
toolbar := LayoutMorph newRow separation: 2.
button := PluggableButtonMorph model: self action: #startGame :: 
   enableSelector: #isStopped;
   icon: Theme current playIcon;
   borderWidth: 2;
   borderColor: Color black;
   setBalloonText: 'Play the game';
   morphExtent: 32 asPoint.
toolbar addMorph: button.
...
self addMorph: toolbar layoutSpec: LayoutSpec new useMorphHeight

Observe enableSelector: #isStopped, indeed the “Play” button is only active when the game isStopped. The same applies with the “Stop” button – not shown here. The toolbar height – row layout – is shrunk to the height of its submorphs, to do so the toolbar is added to the window with the appropriate specification LayoutSpec new useMorphHeight.

The cards are installed in several rows in the playground, previously emptied. We remember about each card in a special cards array we can access with x and y coordinates, also the position of a card in the playground. The colors to be used are randomly chosen and arranged in:

installCards
| colors  row |
playground removeAllMorphs.
cards := Array2D  newSize: size.
colors := self distributeColors shuffled.
1 to: size y do: [:y |
   row := LayoutMorph newRow.
   1 to: size x do: [:x | | card |
      card := MemoryCard model: self action: #flip: actionArgument: x@y.
      card layoutSpec proportionalWidth: 1; proportionalHeight: 1.
      card cardColor: colors removeFirst.
      row addMorph: card.
      cards at: x@y put: card ].
   playground addMorph: row ]

We make the card interactive, it is a button, when clicked the message #flip: is sent to the game window with argument the position in the cards array and playground:

MemoryCard model: self action: #flip: actionArgument: x@y

Game logic

The core of the game logic is in the flip: method. It first flips and locks6 the clicked card

flip: position
| flippedCards |
(cards at: position) flip; lock.

then it detects if all the flipped cards share the same color. To do so, we do a clever trick of Smalltalk: we collect all the colors of the flipped cards, then converts the collection of color as a Set instance, all duplicated colors are removed. If the size of the resulting set is not 1, it means cards have different colors. In that case, we inform the user with a message, then unflip and unlock the clicked cards:

flippedCards := self flippedCards.
(flippedCards collect: [:aCard | aCard cardColor]) asSet size = 1 ifFalse: [
   "Give some time for the player to see the color of this card "
   self message: 'Colors do not match!'.
   self world doOneCycleNow.
   (Delay forSeconds: 1) wait.
   " Color does not match, unflip the flipped cards and unlock "
   flippedCards do: [:aCard | aCard flip; unlock].
   ^ self]

If the colors match, we check if we reach the tupleSize association count – initialized by default to 2, to make pair of cards. If so, we make the cards to flash and we mark them as done so they can’t be played anymore

   flippedCards size = tupleSize ifTrue: [
      " We found a n-tuple! "
      self message: 'Great!' bold, ' You find a ', tupleSize asString, '-tuple!'.
      flippedCards do: #flash.
      flippedCards do: #setDone.

At this point of the game logic, in the event of a game win, we inform the user and update the game status

      self isGameWon ifTrue: [
         self message: 'Congatuluation, you finished the game!' bold red.
         playing := false] ]

Messages to the user

During the game logic, at several occurrences, we informed the user through messages. The message are printed in the status bar set at initialization time:

installStatusBar
statusBar := TextParagraphMorph new
   padding: 2;
   color: Color transparent;
   borderWidth: 1;
   borderColor: self borderColor twiceLighter ;
   setHeightOnContent.
self addMorph: statusBar layoutSpec: LayoutSpec new useMorphHeight.
self message: 'Welcome to ', 'Memory Game' bold

Its companion method to write a new text message just updates the contents of the TextParagraphMorph instance:

message: aText
statusBar contents: aText ;
   redrawNeeded 

A message sent to the status bar can be more than a plain string, it can be a Text instance with styling attributes. To do so, we send specific messages to a string, for example ’hello’ bold converts the ’hello’ string as a Text set with a bold style.

Examples of styling:

'Hello' red bold.
'Hello ' italic, ' my love' red bold.

To discover more messages, browse the method categories text conversion ... of the CharacterSequence class.

Access and test

In the core logic of the game, we accessed the flipped cards in the playground. It is a matter of selecting the cards both not done and flipped.

flippedCards
^ cards elements select: [:aCard | aCard isDone not and: [aCard isFlipped] ]

The Array2D instance of the cards variable offers access to its cells with x and y coordinates; however it does not offer the whole range of the Collection protocol, and particularly the select: method. Nevertheless, its underneath elements attribute is an Array, part of the Collection hierarchy, we use it to get the whole power of the Collection protocol.

We proceed the same to select the done cards

doneCards
^ cards elements select: #isDone

and undone cards are selected by a subtracting operation, prone to resist to code evolution in the card protocol

undoneCards
^ cards elements asOrderedCollection 
   removeAll: self doneCards;
   yourself

In the core logic of the game, we test if the game is won, it is a matter of testing if all the cards are done, in that case this count is equal to the number of cards in the game

isGameWon
^ (cards elements select: #isDone) size = (size x * size y)

The remaining methods of the game does not require comment, they can be read in the complete source code of the Memory Game v1.


Footnotes

(6)

The #lock message is part of the Morph protocol, it suppresses all kind of user interaction with a given morph.