In the first part of this two-part series, we laid the foundation for transitioning between screens and for drawing the hangman using Corona's drawing API. In the second and final part of this series, we will implement the game logic, and transition to a win screen where the hangman will do a dance along with some music. Lets get started.
1. Creating the Word to Guess
Step 1: Reading a Text File
The game reads in words from a text file that contains thousands of words. We will filter the words based on their length and add them to one of three lists, words that are 5 characters long or less, words that are 9 characters long or less, and words that are 13 characters long or less.
By having three separate word lists, we could integrate a difficulty level based on word length. We will not be doing that in this tutorial, but the words will already be separated into separate lists should you decide to pursue that option on your own.
Add the following code to gamescreen.lua.
function readTextFile() local path = system.pathForFile( "wordlist.txt", system.ResourceDirectory) local file = io.open( path, "r" ) for line in file:lines() do --If targeting Windows Operating System comment the following line out -- line = string.sub(line, 1, #line - 1) if(#line >=3 and #line <=5)then table.insert(words5,line) elseif (#line >=3 and #line<=9)then table.insert(words9,line) elseif (#line >=3 and #line<=13)then table.insert(words13,line) end end io.close( file ) file = nil end
The readTextFile
function reads in the text file wordlist.txt. We loop through each line of wordlist.txt and, depending on the word's length, insert it into one of three tables, words5
, words9
, or words13
.
Windows and Unix-based systems handle line endings differently. On Windows, there will be an extra character, which we can remove using the string.sub
method. If you are using a Windows machine, you need to add that line of code by removing the --
preceding it.
Whenever you read a file, it's important to remember that once you're done, you should close
and nil
out the file as shown in the above implementation.
Invoke this function in scene:create
. I placed it at the very top, above the other function calls.
function scene:create( event ) local group = self.view readTextFile() drawChalkBoard(1,1,1) --SNIP-- end
Step 2: CreateGuessWord
The createGuessWord
function is responsible for getting a random word from the list and returning it. Add the following code to gamescreen.lua.
function createGuessWord() guessWord = {} local randomIndex = math.random(#words5) theWord = words5[randomIndex]; print(theWord) for i=1, #theWord do local character= theWord:sub(i,i) if(character == "'")then guessWord[i] ="'"; elseif(character=="-")then guessWord[i] = "-" else guessWord[i]="?"; end end local newGuessWord = table.concat(guessWord) return newGuessWord; end
We use a table, guessWord
, to store each letter of the word. The reason for this is that strings are immutable in Lua, meaning that we cannot change a character of the string.
We first generate a random number randomIndex
, which will be a number from 1 to however many items are in the table. We then use that number to get a word from the table.
We loop over the word and get a reference to the current character by using the string.sub
method. If the current character is an apostrophe or a dash, we put that into the table guessword
, otherwise we put a question mark into the table.
We then transform the table guessWord
into a string newGuessWord
using the table.concat
method. Lastly, we return newGuessWord
.
Step 3: CreateGuessWordText
The createGuessWordText
function creates the Text
at the top of the game area that will change depending on the player's guess.
function createGuessWordText() local options = { text = createGuessWord(), x = 384, y = 70, width = 700, --required for multi-line and alignment font = native.systemFontBold, fontSize = 50, align = "center" --new alignment parameter } guessWordText = display.newText(options) guessWordText:setFillColor(0,0,0) scene.view:insert(guessWordText) end
The options
table holds the various configuration options for the Text
. Because the createGuessWord
function returns a word as a string, we can just invoke it when setting the text
property.
We create the Text
by invoking the newText
method on Display
, passing in the options table. We then set its color and insert it into the scene's view.
Invoke this function in scene:create
as shown below.
function scene:create( event ) --SNIP-- drawGallows() createGuessWordText() end
A Word about Metatables
The Lua programming language does not have a class system built in. However, by using Lua's metatable construct we can emulate a class system. There is a good example on the Corona website, showing how to implement this.
An important thing to note is that Corona's Display
objects cannot be set as the metatable. This has to do with how the
underlying C language interfaces with them. A simple way to get around
this is to set the Display
object as a key on a new table and then set that table as the metatable. This is the approach that we'll take in this tutorial.
If you read the above article on the Corona website, you will have noticed that the __Index
metamethod was being used on the metatable. The way the __Index
metamethod works, is that when you try to access an absent field in a table, it triggers the interpreter to look for an __Index
metamethod. If the __Index
is there, it will look for the field and provide the result, otherwise it will result in nil
.
2. Implementing the GrowText
Class
The game has Text
that will grow from small to large over a short period of time, when the user wins or loses the game. We will create this functionality as a module. By having this code as a module, we can reuse it in any project that requires this functionality.
Add the following to growtext.lua, which you created in the first part of this series.
local growText = {} local growText_mt = {__index = growText} function growText.new(theText,positionX,positionY,theFont,theFontSize,theGroup) local theTextField = display.newText(theText,positionX,positionY,theFont,theFontSize) local newGrowText = { theTextField = theTextField} if(theGroup ~=nil)then theGroup:insert(theTextField) end return setmetatable(newGrowText,growText_mt) end function growText:setColor(r,b,g) self.theTextField:setFillColor(r,g,b) end function growText:grow() transition.to( self.theTextField, { xScale=4.0, yScale=4.0, time=2000, iterations = 1,onComplete=function() local event = { name = "gameOverEvent", } self.theTextField.xScale = 1 self.theTextField.yScale = 1 Runtime:dispatchEvent( event ) end } ) end function growText:setVisibility(visible) if(visible == true)then self.theTextField.isVisible = true else self.theTextField.isVisible = false end self.theTextField.xScale = 1 self.theTextField.yScale = 1 end function growText:setText(theText) self.theTextField.text = theText end return growText
We create the main table growText
and the table to be used as the metatable, growText_mt
. In the new
method, we create the Text
object and add it to the table newGrowText
that will be set as the metatable. We then add the Text
object to the group
that was passed in as a parameter, which will be the scene
's group in which we instantiate an instance of GrowText
.
It's important to make sure that we add it to the scene
's group so it
will be removed when the scene is removed. Finally, we set the
metatable.
We have four methods that access the Text
object and perform operations on its properties.
setColor
The setColor
method sets the color on the Text
by invoking the setFillColor
method, which takes as parameters the R, G, and B values numbers from 0 to 1.
grow
The grow
method uses the Transition library to make the text grow. It enlarges the text by using the xScale
and yScale
properties. The onComplete
function gets invoked once the transition is complete.
In this onComplete
function, we reset the Text
's xScale
and yScale
properties to 1 and dispatch an event. The reason we dispatch an event here is to inform the gamescreen that the Text
has finished its transition, and therefore the game round is over.
This will all become clear soon, but you may want to read up on dispatchEvent
in the documentation.
setVisibility
The setVisibility
method simply sets the visibility of the Text
, depending on whether true
or false
was passed in as a parameter. We also reset the xScale
and yScale
property to 1.
setText
The setText
method is used to set the actual text
property, depending on whatever string was passed in as the parameter.
Lastly, we return the growText
object.
3.createWinLoseText
The createWinLoseText
function creates a GrowText
object that will show either "YOU WIN!" or "YOU LOSE!", depending on whether the user wins or loses a round. Add the following code to gamescreen.lua.
function createWinLoseText() winLoseText = growText.new( "YOU WIN",display.contentCenterX,display.contentCenterY-100, native.systemFontBold, 20,scene.view) winLoseText:setVisibility(false) end
We invoke this function in scene:create
as shown below.
function scene:create( event ) --SNIP-- drawGallows() createWinLoseText() end
4. setupButtons
The setupButtons
function sets up the buttons, draws them to the screen, and adds an event listener that will call the function checkLetter
. The checkLetter
function is where the game's logic takes place.
function setupButtons() local xPos=150 local yPos = 600 for i=1, #alphabetArray do if (i == 9 or i == 17) then yPos = yPos + 65 xPos = 150 end if (i == 25) then yPos = yPos + 65 xPos = 330 end local tempButton = widget.newButton{ label = alphabetArray[i], labelColor = { default ={ 1,1,1}}, onPress = checkLetter, shape="roundedRect", width = 40, height = 40, cornerRadius = 2, fillColor = { default={0, 0, 0, 1 }, over={ 0.5, 0.5, 0.5, 0.4 } }, strokeColor = { default={ 0.5, 0.5, 0.5, 0.4 }, over={ 0, 0, 0, 1 } }, strokeWidth = 5 } tempButton.x = xPos tempButton.y = yPos xPos = xPos + 60 table.insert(gameButtons,tempButton) end end
We first set the initial x
and y
positions of the buttons. We then run a for
loop over the alphabetArray
, which in turn creates a button for every letter of alphabetArray
. We want eight buttons per row so we check if i
is equal to 9 or 17, and, if true, we increment the yPos
variable to create a new row and reset the xPos
to the beginning position. If i
is equal to 25, we are on the last row and we center the last two buttons.
We create a tempButton
by using the newButton
method of the widget class, which takes a table of options. There are several ways to affect the visual appearance of the buttons. For this game, we are using the Shape Construction option. I highly suggest you read the documentation on the Button object to learn more about these options.
We set the label
by indexing into alphabetArray
and set the onPress
property to call checkLetter
. The rest of the options have to do with the visual appearance and are better explained by reading the documentation as mentioned earlier. Lastly, we insert the tempButton
into the table gameButtons
so we can reference it later.
If you now invoke this method from scene:create
, you should see that the buttons are drawn to the screen. We cannot tap them yet though, because we have not created the checkLetter
function. We'll do that in the next step.
function scene:create( event ) --SNIP-- createGuessWordText() createWinLoseText() setupButtons() end
5. checkLetter
The game's logic lives in the checkLetter
function. Add the following code to gamescreen.lua.
function checkLetter(event) local tempButton = event.target local theLetter = tempButton:getLabel() theLetter = string.lower(theLetter) local correctGuess = false local newGuessWord = "" tempButton.isVisible = false for i =1 ,#theWord do local character= theWord:sub(i,i) if(character == theLetter)then guessWord[i] = theLetter correctGuess = true end end newGuessWord = table.concat(guessWord) guessWordText.text = newGuessWord if(correctGuess == false)then numWrong = numWrong +1 drawHangman(numWrong); end if(newGuessWord == theWord)then wonGame = true didWinGame(true) end if(numWrong == 6) then for i =1 , #theWord do guessWord[i] = theWord:sub(i,i) newGuessWord = table.concat(guessWord) guessWordText.text = newGuessWord; end didWinGame(false) end end
The first thing we do is, get the letter the user has guessed by invoking the getLabel
method on the button. This returns the button's label
, a capitalized letter. We convert this letter to lowercase by invoking the lower
method on string
, which takes as its parameter the string to be lowercased.
We then set correctGuess
to false
, newGuessWord
to an empty string, and hide the button the user has tapped, because we don't want the user to be able to tap the button more than once per round.
Next, we loop over theWord
, get the current character by using the string.sub
method, and compare that character to theLetter
. If they are equal, then the user has made a correct guess and we update that particular letter in guessWord
, setting correctGuess
totrue
.
We create newGuessWord
by using the table.concat
method, and update the guessWordText
to reflect any changes.
If correctGuess
is still false
, it means the user has made an incorrect guess. As a result, we increment the numWrong
variable and invoke the drawHangman
function, passing in numWrong
. Depending on how many wrong guesses the user has made, the drawHangman
function will draw the hangman as appropriate.
If newGuessWord
is equal to theWord
, it means the user has guessed the word and we update wonGame
totrue
, calling the didWinGame
function and passing in true
.
If numWrong
is equal to 6, it means the user has used up all their guesses and the hangman has been fully drawn. We loop through theWord
and set every character in guessWord
equal to the characters in theWord
. We then show the user the correct word.
This bit of code should make sense by now as we have done something similar a couple of times before. Lastly, we call didWinGame
and pass in false
.
6. Winning and Losing
Step 1: didWinGame
The didWinGame
function is called when the use either wins of loses a round.
function didWinGame(gameWon) hideButtons() winLoseText:setVisibility(true) if(gameWon == true)then winLoseText:setText("YOU WIN!!") winLoseText:setColor(0,0,1) else winLoseText:setText("YOU LOSE!!") winLoseText:setColor(1,0,0) end winLoseText:grow() end
The first thing we do is invoke hideButtons
, which, as the name suggests, hides all of the buttons. We set the winLoseText
to be visible, and, depending on whether the user won or lost the round, set its text and color as appropriate. Lastly, we invoke the grow
method on the winLoseText
.
As we saw earlier in this tutorial, once the text has finished growing, it dispatches an event. We need to use the Runtime
to listen for that event. We will be coding this functionality in the upcoming steps.
Step 2: Showing and Hiding the Buttons
The showButtons
and hideButtons
functions show and hide the buttons by looping through the gameButtons
table, setting each of the button's visibility.
function hideButtons() for i=1, #gameButtons do gameButtons[i].isVisible = false end end function showButtons() for i=1, #gameButtons do gameButtons[i].isVisible = true end end
Step 3: drawHangman
The drawHangman
function takes a number as a parameter. Depending on what that number is, it draws a certain part of the hangman.
function drawHangman(drawNum) if(drawNum== 0) then drawGallows(); elseif(drawNum ==1)then drawHead(); elseif(drawNum == 2) then drawBody(); elseif(drawNum == 3) then drawArm1(); elseif(drawNum == 4) then drawArm2(); elseif(drawNum == 5) then drawLeg1(); elseif(drawNum == 6) then drawLeg2(); end end
Step 4: Test Progress
It has been quite a while since we have checked our progress, but if you test now you should be able to play a few rounds. To reset the game go to File > Relaunch in the Corona Simulator. Remember, the correct word is being printed to the console so that should help you test everything is working as it should.
When the winLoseText
is finished growing, we will start a new round. If the user has won the round, we will go to a new scene where the hangman will do a happy dance. If the user has lost, we will reset everything in gamescreen.lua and begin a new round.
Before we do any of that, however, we need to listen for the gameOverEvent
that is being dispatched from the growText
class.
7. Game Over
Step 1: Listening for gameOverEvent
Add the following to the scene:show
method.
function scene:show( event ) --SNIP-- if ( phase == "did" ) then Runtime:addEventListener( "gameOverEvent", gameOver ) end end
We pass the gameOverEvent
as the first argument of the addEventListener
method. When a gameOverEvent
is dispatched, the gameOver
function is called.
We should also remove the event listener at some point. We do this in the scene:hide
method as shown below.
function scene:hide( event ) local phase = event.phase if ( phase == "will" ) then Runtime:removeEventListener( "gameOverEvent", gameOver ) end end
Step 2: gameOver
Add the following code to gamescreen.lua.
function gameOver() winLoseText:setVisibility(false) if(wonGame == true)then composer.gotoScene("gameoverscreen") else newGame() end end
If the user has won the game, we invoke the gotoScene
method on the composer
object and transition to the gameoverscreen. If not, we call the newGame
method, which resets the game and creates a new word.
Step 3: newGame
The newGame
function resets some variables, sets the buttons to be visible, clears the hangmanGroup
, and creates a new word.
function newGame() clearHangmanGroup() drawHangman(0) numWrong = 0 guessWordText.text = createGuessWord() showButtons() end
Most of this code should look familiar to you. The clearHangmanGroup
function is the only thing new here and we will look at that function in the next step.
Step 4: clearHangmanGroup
The clearHangmanGroup
simply loops through the hangmanGroup
's numChildren
and removes them. Basically, we are clearing everything out so we can start drawing afresh.
function clearHangmanGroup() for i = hangmanGroup.numChildren, 1 ,-1 do hangmanGroup[i]:removeSelf() hangmanGroup[i]=nil end
Step 5: Test Progress
We are at a point where we can test the progress once again. If you test the game, you can lose a game and a new game should start. You can do this for as long as you wish. In the next step we will get the gameoverscreen wired up.
8. Game Over Screen
Create a new file gameoverscreen.lua and add the following code to it.
local composer = require( "composer" ) local scene = composer.newScene() local hangmanSprite local hangmanAudio function scene:create( event ) local group = self.view drawChalkBoard() local options = { width = 164,height = 264,numFrames = 86} local hangmanSheet = graphics.newImageSheet( "hangmanSheet.png", options ) local sequenceData = { { start=1, count=86, time=8000, loopCount=1 } } hangmanSprite = display.newSprite( hangmanSheet, sequenceData ) hangmanSprite.x = display.contentCenterX hangmanSprite.y = display.contentCenterY hangmanSprite.xScale = 1.5 hangmanSprite.yScale = 1.5 group:insert(hangmanSprite) end function scene:show( event ) local phase = event.phase local previousScene = composer.getSceneName("previous") composer.removeScene(previousScene) if ( phase == "did" ) then hangmanSprite:addEventListener( "sprite", hangmanListener ) hangmanSprite:play() hangmanAudio = audio.loadSound( "danceMusic.mp3" ) audio.play(hangmanAudio) end end function scene:hide( event ) local phase = event.phase if ( phase == "will" ) then hangmanSprite:removeEventListener( "sprite", hangmanListener ) audio.stop(hangmanAudio) audio.dispose(hangmanAudio) end end function drawChalkBoard() local chalkBoard = display.newRect( 0, 0, display.contentWidth, display.contentHeight ) chalkBoard:setFillColor(1,1,1 ) chalkBoard.anchorX = 0 chalkBoard.anchorY = 0 scene.view:insert(chalkBoard) end function hangmanListener( event ) if ( event.phase == "ended" ) then timer.performWithDelay(1000,newGame,1) end end function newGame() composer.gotoScene("gamescreen") end scene:addEventListener( "create", scene ) scene:addEventListener( "show", scene ) scene:addEventListener( "hide", scene ) return scene
The hangmanSprite
is a SpriteObject
that will be used for the dancing animation. The hangmanAudio
is an AudioObject
that will be used to play some music while the hangman does its dance.
Step 1: Animating the Hangman
As I mentioned, the hangmanSprite
is a SpriteObject
instance and by having the hangmanSprite
be a sprite instead of a regular image, we can animate it. The hangmanSprite
has 86 separate images, each one being a different frame for the animation. You can see this by opening hangmanSheet.png in an image editor.
The options table holds the width
, height
, and numFrames
of the individual images in the larger image. The numFrames
variable contains the value of the number of smaller images. The hangmanSheet
is an instance of the ImageSheet
object, which takes as its parameters the image and the options table.
The sequenceData
variable is used by the SpriteObject
instance, the start
key is the image you wish to start the sequence or animation with, and the count
key is how many total images there are in the animation. The time
key is how long it will take the animation to play and the loopCount
key is how many times you wish the animation to play or repeat.
Lastly, you create the SpriteObject
instance by passing in the ImageSheet
instance and sequenceData
.
We set the hangmanSprite
's x
and y
positions, and scale it up to 1.5 its normal size by using the xScale
and yScale
properties. We insert it into the group
to make sure it is removed whenever the scene
is removed.
Step 2: Play Audio
Inside scene:show
, we remove the previous scene, add an event listener to the hangmanSprite
and invoke its play
method. We instantiate the hangmanAudio
by invoking the loadSound
method, passing in danceMusic.mp3. Lastly we call the play
method to start the sound playing.
In the scene:hide
method, we remove the event listener from the hangmanSprite
, invoke stop
on the audio instance, and invoke the dispose
method. The dispose
method ensures that the memory that was allocated to the audio
instance is released.
Step 3: Cleaning Up
In the hangmanListener
, we check if it is in the end
phase, and, if true, it means the animation has finished playing. We then invoke timer.performWithDelay
. The timer fires after one second, invoking the newGame
method, which uses composer to transition back to the gamescreen to begin a new game.
Conclusion
This was quite a long tutorial, but you now have a functional hangman game with a nice twist. As mentioned at the beginning of this tutorial, try to incorporate difficulty levels. One option would be to have an options screen and implement a SegmentedControl
where the user could choose between the lists of 5, 9, and 13 letter words.
I hope you found this tutorial useful and have learned something new. Thanks for reading.