Many months ago, I wrote a simple version of Hangman as a desktop Adobe AIR application. It was built using HTML and jQuery and supported a one thousand word database of word choices. This week I looked into porting the application to the Blackberry Playbook. This is what I came up with.
First - I had assumed the port would be simple. The game itself is trivial so I wasn't expecting much trouble. Then something occurred to me. My desktop application listened for key presses to handle letter guesses. As far as I know, that type of setup doesn't make sense on a mobile device. Sure you can get 'keyboard' input, but from what I know it can only be done by using a form field of some sort. To get around this I decided to simply use buttons. Here is an example:
As you can see, the buttons look rather nice. I don't have a physical Playbook yet (no one does outside of RIM I guess), but it looks like it would work well. I'm not happy with "Z" hanging there by itself and would love some suggestions on how it could be improved. I built out this "keyboard" by hand:
<s:TileGroup id="tileLayoutView" requestedColumnCount="5" click="letterButtonClicked(event)"> <s:Button id="btnA" label="A" /> <s:Button id="btnB" label="B" /> <s:Button id="btnC" label="C" /> (I deleted some lines here.) <s:Button id="btnX" label="X" /> <s:Button id="btnY" label="Y" /> <s:Button id="btnZ" label="Z" /> </s:TileGroup>
After a suggestion by Joe Rinehart I tried using a Datagroup, but ran into issues updating the UI controls on the inside. (I'll happily go into details about the issues i ran into if anyone wants to hear more.)
Outside of that the other big change was how the application was laid out. I don't have CSS, but frankly Flex's layout controls are a lot easier for me to work with. Not to offend anyone's delicate sensibilities but laying stuff out in a Flex application reminds me of tables. Using CSS reminds me more of visiting a sadistic dentist. Sorry. Anyway, let me share the code. Here is the initial view which simply sets up the database connection:
<?xml version="1.0" encoding="utf-8"?> <s:MobileApplication xmlns:fx="http://ns.adobe.com/mxml/2009" xmlns:s="library://ns.adobe.com/flex/spark" firstView="views.HangmanHome" initialize="init()"> <fx:Declarations> <!-- Place non-visual elements (e.g., services, value objects) here --> </fx:Declarations> <fx:Script> <![CDATA[ protected var dbConnection:SQLConnection = new SQLConnection; private function init():void { var dbFile:File = File.applicationDirectory.resolvePath("install/words.db"); dbConnection.open(dbFile); navigator.firstViewData = {dbCon:dbConnection}; } ]]> </fx:Script> </s:MobileApplication>
Here is the main view where of the logic occurs. Note that one feature I did not port was updating the game history. I figured that was pretty trivial and I'd add it later if I wanted.
<?xml version="1.0" encoding="utf-8"?> <s:View xmlns:fx="http://ns.adobe.com/mxml/2009" xmlns:s="library://ns.adobe.com/flex/spark" title="Hangman" xmlns:mx="library://ns.adobe.com/flex/mx" viewActivate="init(event)"> <fx:Declarations> <!-- Place non-visual elements (e.g., services, value objects) here --> </fx:Declarations> <fx:Style> @namespace s "library://ns.adobe.com/flex/spark"; @namespace mx "library://ns.adobe.com/flex/mx"; #blankWord { font-size:70px; font-weight: bold; } #gameStatus { font-weight: bold; } </fx:Style> <fx:Script> <![CDATA[ import model.Game; import mx.collections.ArrayCollection; import mx.core.IVisualElement; import spark.components.BorderContainer; private var game:Game; [Embed (source="images/Hangman-1.png" )] public static const H1:Class; [Embed (source="images/Hangman-2.png" )] public static const H2:Class; [Embed (source="images/Hangman-3.png" )] public static const H3:Class; [Embed (source="images/Hangman-4.png" )] public static const H4:Class; [Embed (source="images/Hangman-5.png" )] public static const H5:Class; [Embed (source="images/Hangman-6.png" )] public static const H6:Class; [Embed (source="images/Hangman-7.png" )] public static const H7:Class; [Embed (source="images/Hangman-8.png" )] public static const H8:Class; [Embed (source="images/Hangman-9.png" )] public static const H9:Class; [Embed (source="images/Hangman-10.png" )] public static const H10:Class; private function init(event:Event):void { trace('init in view called'); initGame(); } private function letterButtonClicked( event : Event ):void { if(!game.isGameOver()) { var button : Button = event.target as Button; if(button) { trace("clicked: " + button.label); game.pickLetter(button.label); button.enabled = false; drawWord(); drawHangman(); if(game.isGameOver()) { handleGameOver(); } } } } private function initGame():void { gameStatus.text = ""; newGameButton.visible = false; for(var i:int=65; i<91; i++) { var s:String = String.fromCharCode(i); trace(s); this["btn"+s].enabled=true; } game = new Game(); //begin by picking a word game.setChosenWord(pickRandomWord()); //draws the blank/letters drawWord(); //draws the hangman drawHangman(); } private function drawHangman():void { var misses:int = game.getMisses(); if(misses == 0) hangmanImage.source = null; else { hangmanImage.source = HangmanHome["H"+(misses+1)]; } } private function drawWord():void { blankWord.text = game.drawWord(); } private function handleGameOver():void { if(game.playerWon()) { gameStatus.text = "Congratulations, you won the game!"; } else { gameStatus.text = "Sorry, but you lost the game!"; } newGameButton.visible = true; } private function pickRandomWord():String { var sql:SQLStatement = new SQLStatement(); sql.text = "select word from words order by random() limit 1"; sql.sqlConnection = data.dbCon; sql.execute(); var sqlResult:SQLResult = sql.getResult(); trace('random word is '+sqlResult.data[0].word); return sqlResult.data[0].word.toUpperCase(); } ]]> </fx:Script> <s:actionContent> <s:Button height="100%" label="Exit" click="NativeApplication.nativeApplication.exit()" /> </s:actionContent> <s:layout> <s:HorizontalLayout paddingTop="10" paddingLeft="10" paddingRight="10" gap="10"/> </s:layout> <s:TileGroup id="tileLayoutView" requestedColumnCount="5" click="letterButtonClicked(event)"> <s:Button id="btnA" label="A" /> <s:Button id="btnB" label="B" /> <s:Button id="btnC" label="C" /> <s:Button id="btnD" label="D" /> <s:Button id="btnE" label="E" /> <s:Button id="btnF" label="F" /> <s:Button id="btnG" label="G" /> <s:Button id="btnH" label="H" /> <s:Button id="btnI" label="I" /> <s:Button id="btnJ" label="J" /> <s:Button id="btnK" label="K" /> <s:Button id="btnL" label="L" /> <s:Button id="btnM" label="M" /> <s:Button id="btnN" label="N" /> <s:Button id="btnO" label="O" /> <s:Button id="btnP" label="P" /> <s:Button id="btnQ" label="Q" /> <s:Button id="btnR" label="R" /> <s:Button id="btnS" label="S" /> <s:Button id="btnT" label="T" /> <s:Button id="btnU" label="U" /> <s:Button id="btnV" label="V" /> <s:Button id="btnW" label="W" /> <s:Button id="btnX" label="X" /> <s:Button id="btnY" label="Y" /> <s:Button id="btnZ" label="Z" /> </s:TileGroup> <!-- block 2 is a v block, top row is pic + instruction block, row 2 is word --> <s:VGroup width="67%" height="100%"> <s:HGroup width="100%" height="80%" horizontalAlign="center"> <!-- hangman goes here --> <s:Image id="hangmanImage" width="50%" /> <!-- nstructions, status,e tc --> <s:VGroup id="rightGroup" width="50%" paddingLeft="5" paddingRight="5" gap="10"> <s:Label width="100%" text="To begin, simply type the letter you would like to guess. Right answers will help reveal the mystery word. Wrong answers will lead to your untimely demise!" /> <s:Label id="gameStatus" width="100%" /> <s:Button id="newGameButton" label="New Game" visible="false" click="initGame()" /> </s:VGroup> </s:HGroup> <s:Label id="blankWord" width="100%" /> </s:VGroup> </s:View>
And finally - the simple Game object I created.
public class Game { private var _chosenWord:String; private var _usedLetters:Object = new Object(); private var _misses:int = 0; public function Game() { } public function drawWord():String { var s:String = ""; for(var i:int=0; i<_chosenWord.length; i++) { var thisLetter:String = _chosenWord.substr(i, 1); if(_usedLetters[thisLetter]) s+= thisLetter; else s+= "-"; } return s; } public function getMisses():int { return _misses; } public function isGameOver():Boolean { if(_misses == 9 || drawWord() == _chosenWord) return true; return false; } public function pickLetter(s:String):void { _usedLetters[s] = 1; if(_chosenWord.indexOf(s) == -1) _misses++; } public function playerWon():Boolean { return (isGameOver() && drawWord() == _chosenWord); } public function setChosenWord(s:String):void { _chosenWord = s; } } }package model {
And a final screen shot so you can see the result you don't want to see:
I've attached a zip of the project to the blogentry.