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
Figure 4.12: Memory color game
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.
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.
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
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] ]
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.
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.
The #lock message is part of the Morph protocol, it suppresses all kind of user interaction with a given morph.