LOOPE - Lingo Object Oriented Programming Environment by Irv Kalb
Section 3 - Section 3 - Behaviors and Objects Together
In this chapter we will build a simple game in order to demonstrate how to create a program using behaviors that communicate with objects. The game we will use to demonstrate this concept is a standard memory game.
Game Rules
The rules of the memory game are very simple. For those old enough to remember, the game is similar to the TV game show "Concentration". The playing area consists of an even number of cards. There can be any number of pairs of matching cards. The cards are randomized and placed face down in a playing area. A player chooses a card, which turns over to reveal a picture or word, then chooses a second card and turns it over in an attempt to find a match. If the two cards match, the pair is eliminated from the playing area. If the cards do not match, both cards are turned face down onto the playing area. The goal of the game is to eliminate all cards by finding all matching pairs.
Because this is an E-Book, we can show the final game we intend to build as a Shockwave movie here:
Thinking about implementation
There are many different ways this game could be implemented. However, because this is a book on object oriented programming, we will show how to use objects to build our solution. Even after deciding to use objects, there are still a number of approaches we could use. For example, we could put all the code in a single parent script. Or conversely, we could all put the code in a single behavior and attach it to all cards.
Early in this book, we stated that behaviors and objects are very similar, but that one of the basic differences is that behaviors are associated with a sprite on the stage, whereas objects are not connected directly to anything on the stage. So it seems obvious that we should put code in a behavior to respond to mouse clicks on the cards. But when a card is clicked on, we must somewhere remember the value of the currently clicked on card. We also need to remember the state of the game. Specifically, we need to remember if the user has just clicked on a first card or a second card. When a user clicks on a second card, we need to compare the value of the second card with the value of the first card. Finally, we must have some code somewhere that checks for the end of the game.
In thinking through this game, it seems that some of the functionality should logically be built at the card "level", but some other functionality seems to fit more at the game "level". Therefore, it seems reasonable to use a mixed approach with a game object that controls the overall game, and a behavior that is attached to every card.
As a first pass, let's identify the "verbs" that describe the actions of the game. Once we figure out what needs to be done to build the game, we can figure out which level (game versus card) should be responsible for implementing each. Some of the obvious actions include: randomizing the cards for a new game, responding to mouse clicks on a cards, revealing a card (turning it from face down to face up), covering a card (turning it from face up to face down). removing or "hiding" two cards when we get a match, remembering the state of the game (is this the first card of a pair or the second), checking for a match, checking for end of the game. There probably will be more that we will realize as we start to actually code the game.
Our goal here is to divide up responsibility between the game object and the behavior which will be attached to every card sprite (or another behavior on the New Game button). Let's start going down this list and try to assign responsibilities to the proper level. (Some people in the object oriented programming community refer to this as "object oriented analysis" or "OOA".) In doing this exercise, we should consider what can most easily be done at which level.
Action
|
Game Object
|
Card or button behavior
|
Randomize cards for new game
|
Yes
|
|
Catch mouse clicks |
Yes
|
|
Reveal a card |
Yes
|
|
Cover a card |
Yes
|
|
Hide a card |
Yes
|
|
Decide if there is a match
|
Yes
|
|
Remember state of the game
|
Yes
|
|
Check for a match
|
Yes
|
|
Check for end of game
|
Yes
|
|
Catch clicks on the New Game button
|
Yes
|
|
Dealing with the New Game button
|
Yes
|
Notice that the card behavior will be responsible for mouse clicks and for revealing, covering and hiding a card. This is because these are all screen related actions. The game object will be responsible for the logic of the game and for making decisions about the game (do we have a match?, is the game over?). The user interacts with the cards or the New Game button. As the user plays the game, the card and the New Game behaviors will send messages to the game object, which likely will respond by telling behaviors attached to one or more cards to change their state. For example, when the user clicks on card A then card B, if this is a match, the game object will send messages to the behaviors attached to card A and card B telling those cards to hide themselves. If card A and card B do not match, then the game object will need to send a message telling the cards to cover themselves (show the card face down).
Building the game
We'll start by laying out the score. In our sample game, we will use five pairs of cards, (or ten cards), and a single New Game button. Because we start with all cards face down, we only need a single common cast member to show the back side of all cards in the starting state. Here's what the score looks like:
There must be an even number of cards so that every card has exactly one match. Later in this chapter, we will show a trick that will allow us to write the code so that it will work for any number of pairs of cards. For now we'll start with ten cards, that is, five pairs of cards. The frame script is a standard "Go to the frame" loop.
The card behavior
The card behavior seems easier than the game script, so we'll start to build that first. But before writing any code, it is important to see how we have laid out the cast.
Notice that the revealed version of the cards are named Card1, Card2, ... Card5. This will make addressing the cards by name very easy. Also notice that there is a cast member named "Hide". This is a zero pixel cast member. This is a cast member that contains no data. We made this member by going into the paint window, creating a new member, and just giving the new member a name. When there is a match, the code will substitute the Hide cast member for each of the matching card sprites - effectively removing them from the screen.
As we determined, the main things the card behavior needs to do are to respond to mouse clicks, reveal a card, cover a card, and hide a card. So let's start writing some code to handle the basics:
-- Card (Version 1)
property
spriteNum
property psymState -- #covered,
#revealed
property pmCover -- the cover
member
property pmHide -- the hide
member
on beginSprite
me
psymState = #covered
pmCover = sprite(spriteNum).member
pmHide = member("Hide")
end
on mouseDown
me
if psymState = #revealed
then
return
--
already showing
end if
me.mReveal()
end
on
mReveal me
sprite(spriteNum).member
= -- no code yet
psymState = #revealed
end
on
mCover me
sprite(spriteNum).member
= pmCover
psymState = #covered
end
on
mHide me
sprite(spriteNum).member
= pmHide
end
For now, there are three property variables. psymState keeps track of the current state of the sprite. In the beginSprite method, psymState is initialized to #covered, but can be either #covered or #revealed. Also in the beginSprite method, we cache away a reference to both the Cover member and the Hide member into property variables. This is done for speed. If we save away a reference to these members once, it will be faster to use them any time we need them later in the game. Because the single card behavior is applied to every card sprite, each behavior keeps its own reference to these members.
Notice that in the mReveal method, we have no code yet to show the current card. As written, this code will not currently compile. We will fix it shortly when we know more about how the game object script works. The rest of the code should be straightforward. We have separated the mouseDown and mReveal methods just to make a distinction in the working of these methods for clarity. These could easily be combined into the mouseDown method.
To make a connection to the game object (which obviously does not exist yet), in the mouseDown method, we will eventually add a line of code that will tell the game object which card has been clicked on.
The Game script
The main work of the game parent script will be to randomize the cards for a new game and to handle the messages that tells it which card was clicked on. Let's build the new game logic first. The first things to do here are to decide what data is needed to represent the game and how to populate that data. This game is very simple and we can easily represent the entire solution to each round of the game in a single list of answers. The list to build will randomly contain two entries for each of the numbers from 1 to half the number of cards. For example, with 10 cards (or 5 pairs), we would want to generate a randomized list that looks like this with the numbers from 1 to 5, each appearing twice:
[2, 5, 3, 2, 4, 4, 1, 3, 5, 1]
Each entry in the list tell us which card should be displayed by each sprite. Given the above list, the first card sprite will display "Card2", the second card sprite should display "Card5", etc. Here is a routine, called NewGame, that will build such a randomized list. For now, we'll show the following code as a movie level script.
-- Game (Version 1)
on
NewGame
nCards = 10
lAnswers = []
nPairs = nCards / 2
repeat with
i = 1 to
nPairs
nCardsPlaced = count(lAnswers)
randomIndex = random(nCardsPlaced
+ 1)
addAt(lAnswers, randomIndex,
i)
randomIndex = random(ncardsPlaced
+ 2)
addAt(lAnswers, randomIndex,
i)
end repeat
put lAnswers
end
This code is a little tricky. As initialization, for now we hard code in 10 cards, create an empty list for our answers, and find out how many pairs of cards we have. The approach to building the list is to iterate from 1 to the number of pairs, and insert our current loop variable twice randomly into the list. We do this first by getting a count of the number of items already in the list, adding one, then we pick a random number as a spot to insert this answer. Then we repeat the process, but we have to add two because we just made our list bigger. Because we are always adding numbers at randomized locations, the final answer list will be completely randomized.
But now we have two problems to solve. We really don't know how many cards there are and we don't know what to do with this list once we have built it. We somehow need to tell each card behavior what card each sprite should contain.
Tying them together
Back in the chapter on Intersprite Communication, we discussed a technique called "registration". In the earlier context, we talked about how sprites could communicate with each other by sending out special registration message using sendAllSprites. The registration message would be received by a behavior attached to a sprite in a numerically lower channel. Here we will do a similar thing. Instead of using sendAllSprites, the card behavior attached to all card sprites will register themselves with the game object by sending a registration message directly to the game object.
First we must take the above NewGame code (which was written as a movie script) and turn it into a parent script called "Game". Notice the addition of a "new" method and an "mCleanUp" method. If you had entered the above code as a movie script, you would also have to change the type of the script from "movie" to "parent":
-- Game (Version 2)
on new me
return me
end
on mNewGame me
nCards = 10
lAnswers = []
nPairs = nCards / 2
repeat with i = 1
to nPairs
nCardsPlaced = count(lAnswers)
randomIndex = random(nCardsPlaced + 1)
addAt(lAnswers, randomIndex, i)
randomIndex = random(ncardsPlaced + 2)
addAt(lAnswers, randomIndex, i)
end repeat
put lAnswers
end
on mCleanUp me
nothing
end
The game needs to be able to communicate with the sprites, and the sprites need to be able to communicate with the game. But the knowledge of each other's existence must happen in a specific order. In Director, the standard order of events is that the movie first gets a "prepareMovie" message, then as each sprite is instantiated, each behavior attached to the sprite receives a "beginSprite" message, then the movie gets a "startMovie"message, finally the movie moves into frame 1. Because of this ordering of events, we will instantiate the game object first in the prepareMovie handler. Here we add a new movie script which instantiates the game script into a global variable called "goGame" (which stands for global object Game).
--Movie
script
global goGame
on prepareMovie
if objectp(goGame)
then
goGame.mCleanUp()
goGame = VOID
end if
goGame = new(script
"Game")
end
-- Card (Version 2)
global goGame
property
spriteNum
property psymState -- #covered,
#revealed
property pmCover -- the cover
member
property pmHide -- the hide
member
on beginSprite
me
psymState = #covered
pmCover = sprite(spriteNum).member
pmHide = member("Hide")
goGame.mRegister(spriteNum)
end
On the receiving end, the game object must do two things with this information. It must remember in which channels the cards can be found, and it also must determine how many cards there are in the game. In the game object we will add properties plchCards to keep track of the channels for the cards, and pnCards to remember how many cards there are.
-- Game (Version 3)
property plchCards -- list
of channels of cards
property pnCards -- number
of cards
on new
me
plchCards = []
return me
end new
on
mRegister me, ch
append(plchCards, ch)
pnCards = count(plchCards)
end
on mNewGame me
lAnswers = []
nPairs = pnCards / 2
repeat with i = 1
to nPairs
nCardsPlaced = count(lAnswers)
randomIndex = random(nCardsPlaced + 1)
addAt(lAnswers, randomIndex, i)
randomIndex = random(ncardsPlaced + 2)
addAt(lAnswers, randomIndex, i)
end repeat
put lAnswers
end
The basic change is to add the mRegister method. This
is called from each instance of the card behavior (one per card). The
list of channels is needed because it gives the game object a way to communicate
back to each card sprite.
Notice also that we have eliminated our hard-coded number of 10 cards in the mNewGame method, and have replaced it with the property variable pnCards. Each time the mRegister method is called, the value of pnCards will be updated to give us the number of cards so far. When the last card registers itself, we know how many cards there are.
At this point, the game object knows the channel assignments of all the cards, so the next step is to have the game object tell each card behavior what card number is being assigned for this round of the game. To do this, we modify the game object so that after building the randomized answers, it then sends out a message to each card behavior telling each one which card number it should be. Look at the mNewGame method below:
-- Game (Version 4)
property plchCards -- list
of channels of cards
property pnCards -- number
of cards
on new
me
plchCards = []
return me
end new
on
mRegister me, ch
append(plchCards, ch)
pnCards = count(plchCards)
end
on mNewGame me
-- Build up randomized answer
list
lAnswers = []
nPairs = pnCards / 2
repeat with
i = 1 to
nPairs
nCardsPlaced = count(lAnswers)
randomIndex = random(nCardsPlaced
+ 1)
addAt(lAnswers, randomIndex,
i)
randomIndex = random(nCardsPlaced
+ 2)
addAt(lAnswers, randomIndex,
i)
end repeat
--
Assign cards
repeat with
i = 1 to
pnCards
chCard = plchCards[i]
cardNum = lAnswers[i]
sendSprite(chCard, #mSetCard,
cardNum)
end repeat
end
To complete the communication, the card behavior must have a mSetCard behavior that handles this message. Remember the card behavior has an mReveal method that knows how to draw the card on the screen. We tie this all up by adding an mSetCard method to the card behavior, a property variable called psCardName that remembers the name of the card that the game object tells it, and by modifying the mReveal behavior:
-- Card (Version 3)
global goGame
property
spriteNum
property psymState --
#covered,
#revealed
property pmCover -- the cover
member
property pmHide -- the hide
member
property psCardName -- name
of the current card in the cast
on beginSprite
me
psymState = #covered
pmCover = sprite(spriteNum).member
pmHide = member("Hide")
goGame.mRegister(spriteNum)
end
on mouseDown
me
if psymState = #revealed
then
return
--
already showing
end if
me.mReveal()
end
-- Translate the
card number into the matching card member name in the
cast
-- and save it into a property variable
on mSetCard me, cardNum
psCardName = "Card"
& string(cardNum)
end
on
mReveal me
sprite(spriteNum).member
= member(psCardName) -- Show
the card
psymState = #revealed
end
on
mCover me
sprite(spriteNum).member
= pmCover
psymState = #covered
end
on
mHide me
sprite(spriteNum).member
= pmHide
end
Notice in the mSetCard behavior that we save away not just the number of the card, but the full name of the card that can be found in the cast. Only the card behavior knows about this connection, the game has no idea about the naming of the cards in the cast. If you wanted to change the naming convention of the cards, you would only have to change code in the card behavior - the code of the game object would not be affected at all.
Now the communication is complete. All card behaviors tell the game object the channels in which they are located. The game object remembers those channels and later sends a message back to each one telling each what card number each should contain.
Game start up
We have, however, left out something that is very important. How does the game start? On the game screen, there is a button labeled New Game. When the user clicks that button, we have to start a new game. This is very simple because we already built the game object to have an mNewGame method. All we have to do is to add our standard button behavior to this sprite, and attach a script that calls that method:
-- New game button script
global goGame
on mHit me
goGame.mNewGame()
end
As it is currently written, the user must click on the New Game button in order to start the game. This is fine for the second round, third round, etc. of the game. However, the user should not have to click on the button to start the first round. Round one of the game should be set up automatically for the user. We already know that to start the game, all we have to do is to call the mNewGame method of the the game object. We just need an appropriate place to add this call.
We can solve this by adding a clever trick. When we showed the score layout of the game, you may have noticed that the New Game button was in a channel lower than (in a higher channel number than) all card sprites. By the time Director instantiates the New Game button, all the card sprites will have been instantiated and will have registered themselves with the Game object. Therefore, in the beginSprite handler of the New Game's button script, we can add a call to the game object's mNewGame method to start the first round:
-- New game button script
global goGame
on beginSprite
me
goGame.mNewGame()
end
on mHit me
goGame.mNewGame()
end
When the game starts, the following steps are taken:
Game play
Now that everything is set up, we need to work on the game play itself. The user plays the game by clicking on cards. The only change needed to the card behavior is to add a single line in the mouseDown method to tell the game object that a card was hit. As parameters, we will pass both the card name and the channel of the card sprite.
-- Card (Version 4)
global goGame
property
spriteNum
property psymState -- #covered,
#revealed
property pmCover -- the cover
member
property pmHide -- the hide
member
property psCardName -- name
of the current card in the cast
on beginSprite
me
psymState = #covered
pmCover = sprite(spriteNum).member
pmHide = member("Hide")
goGame.mRegister(spriteNum)
end
on mouseDown
me
if psymState = #revealed
then
return
--
already showing
end if
me.mReveal()
goGame.mHitCard(psCardName, spriteNum)
end
It makes sense that the change needed is in the game object. The fundamental change here is to add the mHitCard method. In this method, we must keep track of the current state of the game (first card or second card), add code to see if the second card matched the first card, and announce the results. Further, if there is a match, we must eliminate both cards, and if it is not a match, we must turn both selected cards face down:
-- Game (Version 5)
property plchCards -- list
of channels of cards
property pnCards -- number
of cards
property psFirstCardChosen --
name of the first card of a pair
property pchFirstCardChosen --
channel of the first card of a pair
property psymState -- #noneChosen,
#oneChosen
on new
me
plchCards = []
return me
end new
on
mRegister me, ch
append(plchCards, ch)
pnCards = count(plchCards)
end
on mNewGame me
-- Build up randomized answer
list
lAnswers = []
nPairs = pnCards / 2
repeat with
i = 1 to
nPairs
nCardsPlaced = count(lAnswers)
randomIndex = random(nCardsPlaced
+ 1)
addAt(lAnswers, randomIndex,
i)
randomIndex = random(ncardsPlaced
+ 2)
addAt(lAnswers, randomIndex,
i)
end repeat
--
Assign cards
repeat with
i = 1 to
pnCards
chCard = plchCards[i]
cardNum = lAnswers[i]
sendSprite(chCard, #mSetCard,
cardNum)
end repeat
-- Set
game tracking property variable
psymState = #noneChosen
end
on mHitCard me, sCardName,
chCurrentCard
if psymState = #noneChosen
then --
First card of pair
psFirstCardChosen = sCardName
pchFirstCardChosen = chCurrentCard
psymState = #oneChosen
else --
Second card of pair
if sCardName =
psFirstCardChosen
then --
match
alert("YessirreeeeBob")
sendSprite(pchFirstCardChosen,
#mHide)
sendSprite(chCurrentCard,
#mHide)
psymState = #noneChosen
else
--
not a match
alert("Nope
- sorry.")
sendSprite(pchFirstCardChosen,
#mCover)
sendSprite(chCurrentCard,
#mCover)
psymState = #noneChosen
end
if
end if
end
(Note: Of course in a full implementation of a memory game, you would not use "alert" commands. Instead you would probably want to use sounds and/or transitions to make the game play more enjoyable. Alert commands are used here to make the basic game programming more clear.)
Notice that we have added a few property variables to keep track of the state of the game. Also notice that in the mHitCard method, we don't actually turn cards over or remove them from the screen. Instead, we send the appropriate message to the affected sprites and the card behaviors do the work. This is example of the separation of responsibilities. The game object makes the decision about what to do, and tells the appropriate card behaviors about its decision by calling the appropriate method of the card behaviors. Then the card behavior on each sprite does the actual work of changing its sprite on the screen.
During the game, let's say a user clicks on card 3 then on card 7. Here are the steps that are taken:
Game over
The final thing to do is to add code to check for when the game is over. Not surprisingly, the code for this is in the game object. In fact, it's only a minor change. We only need to keep track of how many matches have been made. We know how many matches need to be made (the number of pairs), so when all matches have been made, the game is over. Here is the modified code to show how to add a count of matches:
-- Game
property
plchCards -- list of channels of cards
property pnCards -- number
of cards
property pnMatches -- number
of matched pairs so far
property psFirstCardChosen --
name of the first card of a pair
property pchFirstCardChosen --
channel of the first card of a pair
property psymState -- #noneChosen,
#oneChosen
property pnPairs -- number
of pairs of cards
on new
me
plchCards = []
return me
end new
on
mRegister me, ch
append(plchCards, ch)
pnCards = count(plchCards)
end
on mNewGame me
-- Build up randomized answer
list
lAnswers = []
pnPairs = pnCards / 2
repeat with
i = 1 to
pnPairs
nCardsPlaced = count(lAnswers)
randomIndex = random(nCardsPlaced
+ 1)
addAt(lAnswers, randomIndex,
i)
randomIndex = random(ncardsPlaced
+ 2)
addAt(lAnswers, randomIndex,
i)
end repeat
--
Assign cards
repeat with
i = 1 to
pnCards
chCard = plchCards[i]
cardNum = lAnswers[i]
sendSprite(chCard, #mSetCard,
cardNum)
sendSprite(chCard, #mCover)
end repeat
-- Set
game tracking property variables
pnMatches = 0
psymState = #noneChosen
end mNewGame
on
mHitCard me, sCardName, chCurrentCard
if psymState = #noneChosen
then --
First card of pair
psFirstCardChosen = sCardName
pchFirstCardChosen = chCurrentCard
psymState = #oneChosen
else --
Second card of pair
if sCardName =
psFirstCardChosen
then --
match
alert("YessirreeeeBob")
sendSprite(pchFirstCardChosen,
#mHide)
sendSprite(chCurrentCard,
#mHide)
pnMatches = pnMatches + 1
if pnMatches
= pnPairs then
alert("Game
over")
end
if
psymState = #noneChosen
else
--
not a match
alert("Nope
- sorry.")
sendSprite(pchFirstCardChosen,
#mCover)
sendSprite(chCurrentCard,
#mCover)
psymState = #noneChosen
end
if
end if
end
on mCleanup me
nothing
end mCleanup
Notice that we have added a property variable called pnMatches. We initialize this variable to zero in the mNewGame method, and increment it every time a match is found in the mHitCard method. A simple check against the pnPairs property variable tells us when the game is over.
Conclusion
In this chapter we have shown how a simple game can be built using behaviors and objects communicating with each other. We have demonstrated how combining objects with behaviors can allow you to easily build powerful entities. The key is dividing up the responsibility of what should be done in objects and what should be handled by behaviors. Objects are used for code that has no direct relation to the screen, but can be very effective in sending messages to a number of different sprites. Behaviors are best where you are controlling a single sprite on the screen. When the same behavior is attached to multiple sprites, each behavior works independently and can have different values or states that keep track of that specific sprite's state and/or screen representation.