《Yii Rapid Application Development Hotshot》

Chapter 6. It's All a Game

All work and no play makes James a dull boy. Game programming evokes a slightly different mindset than developing business applications. We will use these exercises to gain a new perspective. We can learn Yii and have fun too! In this chapter, we will try to prove that.

Mission Briefing

We will make two games that the user can play for fun and practice. We will leverage Yii to do this quickly with a small amount of code.

Why Is It Awesome?

Implementing a game with a development framework like Yii can be challenging and rewarding. If you implement it using the MVC model, you have to come up with a stateful model that makes sense. If you don't take the correct precautions, it is easy for the users to cheat. We can learn a lot by exploring the concepts involved.

Your Hotshot Objectives

  1. Updating the Database and Running Gii for Hangman
  2. Creating a JSON Endpoint for Hangman
  3. Developing the Controller – Creating the DB Entry
  4. Developing the Controller – Making the Rules
  5. Developing the View
  6. Improving the View
  7. Authorized Entry Only
  8. Reusing Code – Making a New Game

Mission Checklist

This project assumes that you have a web development environment prepared. If you do not have one, the tasks in Project 1, Develop a Comic Book Database, will guide you through setting one up. In order to work this project, you will need to set up the project files that have been provided with the book. Refer to the Preface of the book for instructions on downloading these files. The files for this project include a Yii project directory with a database schema. To prepare for the project, follow these steps, replacing the username james with your own username.

  1. Copy the project files into your working directory.
cpr ~/Downloads/project_files/Chapter\ 6/project_files ~/projects/ch6

The source files can be downloaded from the Support page at http://www.packtpub.com/support.

  1. Make the directories that Yii uses web writeable.
cd ~/projects/ch6/
sudo chown -R james:www-data protected/runtime assets protected/models protected/controllers protected/views
  1. Create a link in the webroot directory to the copied directory.
cd /opt/lampp/htdocs
sudo ln -s ~/projects/ch6 cbdb
  1. Import the project into NetBeans (remember to set the project URL to http://localhost/cbdb).
  2. Create a database named cbdb and load the database schema (~/projects/ch6/protected/data/schema.sql) into it.
  3. If you are not using the XAMPP stack or if your access to MySQL is password protected, you should review and update the Yii configuration file (in NetBeans: ch6 | Source Files | protected | config | main.php).
  4. This project requires curl for PHP. If you are using XAMPP on Linux, it should already be enabled. You can verify this by going to http://localhost/xampp/phpinfo.php and looking for the section labeled curl or the option --with-curl=/opt/lampp. If it is not enabled, you will need to determine how to enable it for your particular environment.

Note that the admin password is test.

Updating the Database and Running Gii for Hangman

We're going to develop Hangman. The game will work as follows. A suitable title will be randomly selected from the database. It will be drawn as blanks and the user can begin guessing letters. If the letter is present, it will be filled in. Each time the user misses a letter, a new piece of the man is hung. A list of guessed letters will be kept. No letter can be guessed twice. If the user misses six letters, the entire man is hung and the game is over. We need a place to persist information about games. So in this task, we will create a database table and run Gii to create a model and a controller for the entity.

Prepare for Lift Off

Look in protected/config/main.php and make sure that the srbac debug parameter is set to true.

'srbac' => array(
    'userclass'=>'User', //default: User
    'userid'=>'id', //default: userid
    'username'=>'username', //default:username
    'delimeter'=>'@', //default:-
    'debug'=>true, //default :false
    'pageSize'=>10, // default : 15
    'superUser' =>'Authority', //default: Authorizer
    'css'=>'srbac.css', //default: srbac.css

Engage Thrusters

  1. Connect to the cbdb database and run the following command to create the hangman table:
CREATE TABLE `hangman` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `title` varchar(80) NOT NULL,
  `guessed` varchar(26) NOT NULL default '',
  `fails` tinyint(3) unsigned default 0,
  `token` varchar(32) NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE(`token`)
) ENGINE=InnoDB;
  1. Now run the model generator with Gii and point it to the hangman table, and generate a model named Hangman:
  1. Click on preview and then on generate and you should see the following output:
Generating code using template "/opt/lampp/htdocs/yii-1.1.10.r3566/framework/gii/generators/model/templates/default"...
 generated models/Hangman.php
done!
  1. Now generate a controller with the controller generator in Gii. Input hangman for the controller ID, and create play for the action IDs:
  1. Click on preview and then on generate and you should see the following output:
Generating code using template "/opt/lampp/htdocs/yii-1.1.10.r3566/framework/gii/generators/controller/templates/default"...
 generated controllers/HangmanController.php
 generated views/hangman/create.php
 generated views/hangman/play.php
done!

Objective Complete - Mini Debriefing

We have created a database table called Hangman with the following columns: title, guessed, fails, and token. Each row in the table will represent a single round of hangman. The table title will store the title of the book the user is trying to guess, guessed will be a string containing all the letters that have been guessed (in alphabetical order), and fails will store the number of guesses the user has got wrong. A unique lookup string (a token) that is unrelated to the ID of the record will be stored in the table token. The token should be constructed in such a way that it is very difficult (virtually impossible) to guess. We generated a model to encapsulate the hangman table as a Hangman object. We have generated a controller, named HangmanController.php, with two actions: play and create. The action create will be used to select a title and create a token, at which point it will redirect to play. play should use the token to statefully track the round of hangman. A view for each action has been added. The plan is to use the create view to display errors related to creating a new game, and to develop the view for our game in the play view. Hold these thoughts while we take a very short detour in the next task.

Creating a JSON Endpoint for Hangman

We need a way to fetch all the titles of our books, so we can randomly select one for hangman. We could fetch them all using the book model, but have chosen to instead expose this as a JSON endpoint. In this case, we will be fetching the list of books from the controller using the curl library, but we could just as easily use AJAX to fetch them from the view, or this endpoint could be the start of a web service API we could expose to third parties that would like to use our data outside our application.

Engage Thrusters

  1. Open protected/controllers/BookController.php and add the following lines after the other actions:
public function actionTitlelist()
{
    header('Content-type: application/json');
    $books = Book::model()->findAll();
    $ret = array();
    foreach ($books as $book) {
        $ret[] = $book['title'];
    }
    echo CJSON::encode($ret);
    Yii::app()->end();
}
  1. Open a browser and navigate to http://localhost/cbdb/index.php/book/titlelist. You should see something like the following screenshot:

Objective Complete - Mini Debriefing

This very short task has shown an ad-hoc way to produce useful JSON endpoints. Because the action in the controller calls echo, it prints it in the view, even though there is no render (and no view file).

Classified Intel

There are other ways to accomplish this. You could just put the following code in the Hangman controller we are about to write, in the create action where we will fetch the list of titles:

$books = Book::model()->findAll();
$titles = array();
foreach ($books as $book) {
    $titles[] = $book['title'];
}

Then, you would not have to use curl to fetch the list.

It could be argued that serving JSON data directly through a controller does not strictly adhere to the MVC philosophy, because it is missing a view. If you still want to generate JSON, but you want to explicitly define a view, you can do that as well, by making an appropriate barebones layout and using that in the view. In that case you would call render() in the usual way, and then convert the passed data to JSON in the view.

Developing the Controller – Creating the DB Entry

We will put code in the create action for the Hangman controller to do four basic things:

  1. Pick a title at random.
  2. Generate a token.
  3. On success, create the record, and redirect to play, passing the token as a parameter.
  4. On failure, display the error in the view.

Engage Thrusters

  1. Open protected/controllers/HangmanController.php and add the following function at the top of the HangmanController class:
private function errorAndEnd($action, $error) {
    $this->render($action, array('error' => $error));
    Yii::app()->end();
}

When called, this will pass error to the view and cause the application to terminate.

  1. Then add code to actionCreate() so it looks like the following:
public function actionCreate() {
  $error = '';
  $request = Yii::app()->request;
  $jsonUrl = $request->hostInfo . $request->baseUrl . '/index.php/book/titlelist';
  $ch = curl_init($jsonUrl);
  $options = array(
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => array('Content-type: application/json')
  );
  curl_setopt_array( $ch, $options);  
  $titles = json_decode(curl_exec($ch));
  curl_close($ch);
  if ((!is_array($titles)) || (count($titles) == 0)) {
    $this->errorAndEnd('create', 'No titles found fetching from URL: ' . $jsonUrl);
  }
  for ($count = 0; $count < count($titles); $count++) {
    if (strlen($titles[$count]) < 8) {
      unset($titles[$count]);
    }
  }
  if (count($titles) < 1) {
    $this->errorAndEnd('create', 'No suitable titles found in database.');
  }
  $titles = array_merge($titles);  //Renumber the array
  $this->render('create', array('titles' => $titles));
}
  1. Now open protected/views/hangman/create.php and make it look like the following listing:
<?php
$this->breadcrumbs=array(
  'Hangman'=>array('/hangman'),
 'Create',
);
if (isset($error)) {
  echo("ERROR: $error <br />\n"); 
}
else {
  echo(print_r($titles, 1) . "\n");
}
  1. If all has gone well, you should see the dump of the $titles array when you go to http://localhost/cbdb/index.php/hangman/create: http://localhost/cbdb/index.php/hangman/create.

Now we have verified that we are fetching the list of titles with curl. If this is not working, you may need to install curl or configure PHP.

  1. Now, we will look at token creation. Add another function to the HangmanController class as follows:
private function hangmanToken() {
  $charset = '0123456789abcdef';
  $token = '';
  $charArr = preg_split('//', $charset , 0, PREG_SPLIT_NO_EMPTY);
  for ($count = 0; $count < 32; $count++) {
    $token .= $charArr[mt_rand(0, count($charArr) - 1)];
  }
  return $token;
}

This will create a 32-digit hexadecimal string representing a 128-bit hexadecimal number (the amount of entropy for the token is a maximum of 128 bits). We will save this string, along with our selected title, and then redirect to the play action.

  1. Change the lines for actionCreate() as indicated.
public function actionCreate()
{
    $error = '';
    $request = Yii::app()->request;
    $jsonUrl = $request->hostInfo . $request->baseUrl . '/index.php/book/titlelist';
    $ch = curl_init($jsonUrl);
    $options = array(
      CURLOPT_RETURNTRANSFER => true,
      CURLOPT_HTTPHEADER => array('Content-type: application/json')
    );
    curl_setopt_array( $ch, $options);  
    $titles = json_decode(curl_exec($ch));
    curl_close($ch);
    if ((!is_array($titles)) || (count($titles) == 0)) {
      $this->errorAndEnd('create', 'No titles found fetching from URL: ' . $jsonUrl);
    }
    for ($count = 0; $count < count($titles); $count++) {
      if (strlen($titles[$count]) < 8) {
        unset($titles[$count]);
      }
    }
    if (count($titles) < 1) {
      $this->errorAndEnd('create', 'No suitable titles found in database.');
    }
    $titles = array_merge($titles);  //Renumber the array
    
    
    $hangman = new Hangman;
    $randCount = 0;
    $hangman->title = strtoupper($titles[mt_rand(0, count($titles) - 1)]);
    do {
      if ($randCount > 5) {  //Even one duplicate is *highly* unlikey (1 in 2^128 if mt_rand were truly random)
        $this->errorAndEnd('create', 'Token generation appears to be broken.');
      }
      $hangman->token = $this->hangmanToken();
      $randCount++;
    } while ((Hangman::model()->find('token=:token', array(':token'=>$hangman->token))) != null);
    $hangman->save();
    $this->redirect($request->hostInfo . $request->baseUrl . '/index.php/hangman/play?token=' . $hangman->token);
}
  1. Now redo the view.
<?php
$this->breadcrumbs=array(
  'Hangman'=>array('/hangman'),
  'Create',
);?>
<h1>Hangman Game Start Error</h1>
 
<pre>
<?php echo("ERROR: $error <br />\n"); ?>
</pre>

When you go to http://localhost/cbdb/hangman/create it should redirect to something like http://localhost/cbdb/index.php/hangman/play?token=62f36f5c03237af6f94ae952d5a43 (the token will be different, of course):

You can also look in the database table and see that a record was created with the expected values.

Objective Complete - Mini Debriefing

At the very beginning, we added a function named errorAndEnd(). This function simply calls render() with an error string and then terminates the application. Then, in the first part of this task, we used curl to fetch JSON from the endpoint we created in the previous task and then tested it. When we set CURLOPT_RETURNTRANSFER to true, this tells curl_exec() to return the content of the endpoint as a string. Then, we check to see if any titles were returned, and if none were, we display an error and exit with errorAndEnd(). Next, we loop through the list of returned titles and delete any shorter than eight characters (it seems like hangman is not as fun with very short titles). We check to see that we still have some viable titles, once again returning an appropriate error and ending if our check fails. We renumber the array with array_merge(), because when you use unset to remove items from an ordinal array in PHP, it doesn't renumber them. The function array_merge() can be used as a clever way to fix this. Finally, we call render(), passing the newly renumbered array $titles to the view.

We create a tiny view that checks for an error. If an error is present, it is displayed, otherwise the list of titles is dumped for debugging purposes, using print_r(). This allows us to debug the first part of the task.

Moving on… we needed a token, so we added the function hangmanToken(). This function is written simply in order to convey the concept of a unique token in the simplest way possible. However, it is not the best way to generate a token. You will find more information about token generation in the Classified Intel section of this task.

With the statement $hangman->title = strtoupper($titles[mt_rand(0, count($titles) - 1)]); we select a random title from our list to be uppercased. We then generate a random token and check to make sure it doesn't exist by querying the database. If it does exist, which is extremely unlikely, we create another random token (if we wind up creating an existing token more than five times, something has gone very wrong and we abort). After all this, we add the token to the query string for the URL to play, and redirect there.

Classified Intel

When we discuss random numbers in computer science, we are almost always discussing pseudorandom numbers. Computers are deterministic machines by nature, and it is impossible to generate truly random numbers without expensive and highly specialized hardware. When we talk about the strength of (pseudo) random numbers, we are actually referring to the predictability of those numbers. While mt_rand() generates stronger random numbers than most implementations of rand(), it is not the best way to generate strong random numbers suitable for non-guessable tokens and cryptography. A comprehensive discussion of strong random numbers is outside the scope of this book. However, an understanding of this concept is essential to developing secure applications.

The placeholder function we wrote (hangmanToken()) is sufficient to make a fun and playable game, but the generated tokens may not withstand the scrutiny of a major government, hacker, or cryptographer who is determined to predict their values. If you wish to implement a more secure version of hangmanToken(), we encourage you to do so (see the You Ready to go Gung Ho? A Hotshot Challenge section at the end of this chapter).

Developing the Controller – Making the Rules

We will write the play action in the Hangman controller now. This is where the main part of our game will be implemented. We have to take the rules of the game, and figure out how to track them in the database table in a stateful fashion. (Obviously, we already have a good idea of how we're going to do this, since we've already created our database table and discussed what each column is for.) Then, we have to use Yii to update these states and translate them into meaningful interaction for the user.

Engage Thrusters

  1. Fetch the row from the database using the token that was passed from the create action (or from the play view we will develop later). If no record is found, display an error and terminate. Add the following lines to the beginning of actionPlay() in protected/controllers/HangmanController.php:
$hangman = Hangman::model()->find('token=:token', array(':token'=>$_GET['token']));
if ($hangman == null) {
    $this->errorAndEnd('play', 'Invalid token.');
}
  1. Change $this->render('play'); to $this->render('play', array('token' => $hangman->token)); at the bottom of the function, so we can see what's going on. Now change the view (protected/views/hangman/play.php) to reflect this and let's test these small changes:
<?php
$this->breadcrumbs=array(
    'Hangman'=>array('/hangman'),
    'Play',
);
if (isset($error)) { 
    echo("<h1><center><u>CBDB Hangman Error</u></center></h1><br />\n");
    echo("<h2>Error: $error</h2><br />\n");
}
else {
    echo("<h1><center><u>CBDB Hangman</u></center></h1><br />\n");
    echo("<h2><center>Token: $token</center></h2><br />\n");
}

Now you should see something like the following screenshot:

  1. Put in an invalid token value and you should see the following screenshot:
  1. Add some additional code to actionPlay() and change the render to include the new information.
public function actionPlay()
{
    $hangman = Hangman::model()->find('token=:token', array(':token'=>$_GET['token']));
    if ($hangman == null) {
      $this->errorAndEnd('play', 'Invalid token.');
    }
    $title = strtoupper($hangman->title);
    $guessed = array();
    foreach (preg_split('//', $hangman->guessed , 0, PREG_SPLIT_NO_EMPTY) as $letter) {
      $guessed[$letter] = 1;
    }
    $maskedTitle = '';
    foreach (preg_split('//', $title, 0, PREG_SPLIT_NO_EMPTY) as $letter) {
      if (!isset($guessed[$letter]) && ctype_alpha($letter)) {
        $maskedTitle .= '_ ';
      }
      else {
        $maskedTitle .= $letter . ' ';
      }
    }
    $maskedTitle = preg_replace('/ /', '&nbsp;', $maskedTitle);
    $this->render('play', array('maskedTitle' => $maskedTitle, 'guessed' => $hangman->guessed));
}
  1. Once again, change the view so we can see what's going on:
<?php
  $this->breadcrumbs=array(
    'Hangman'=>array('/hangman'),
    'Play',
  );
  if (isset($error)) { 
    echo("<h1><center><u>CBDB Hangman Error</u></center></h1><br />\n");
    echo("<h2>Error: $error</h2><br />\n");
  }
  else {
    echo("<h1><center><u>CBDB Hangman</u></center></h1><br />\n");
    echo("<h2><center>Title: $maskedTitle</center></h2><br />\n");
    echo("<h2><center>Guessed: $guessed</center></h2><br />\n");
 
  }
?>
  1. Now creating a new game by going to http://localhost/cbdb/index.php/hangman/create or loading an existing game from play should show something like the following screenshot:
  1. Now we need to make a way for the controller to check for wins, losses, and to process guesses. We will add a function and change actionPlay() to accomplish this:
private function assessWin($guesses, $title) {
    $guessArr = array();
    foreach (preg_split('//', strtoupper($guesses), 0, PREG_SPLIT_NO_EMPTY) as $letter) {
        $guessArr[$letter] = true; 
    }
    foreach (preg_split('//', strtoupper($title), 0, PREG_SPLIT_NO_EMPTY) as $letter) {
        if (!isset($guessArr[$letter]) && ctype_alpha($letter)) {
            return false;
        }
    }
    return true;
}
 
public function actionPlay()
{
    $message = '';
    if (!isset($_GET['token'])) {
        $this->errorAndEnd('play', 'No token set.');
    }
    $token = $_GET['token'];
    $hangman = Hangman::model()->find('token=:token', array(':token'=>$token));
    if ($hangman == null) {
        $this->errorAndEnd('play', 'Invalid token.');
    }
 
    $title = strtoupper($hangman->title);
    $win = false;
    $lose = false;
    if ($hangman->fails > 5) {
        $lose = true;
    }
    else {
        $win = $this->assessWin($hangman->guessed, $hangman->title);
        if (!$win && isset($_GET['guess'])) { 
            $guess = strtoupper($_GET['guess']);
            if (strlen($guess) == 1 && ctype_alpha($guess) && !strstr($hangman->guessed, $guess))  {
                if (!strstr($title, $guess)) {
                    $hangman->fails++;
                    if ($hangman->fails > 5) {
                        $lose = true;
                    }
                }
                $hangman->guessed .= $guess;
                $guessed = preg_split('//', $hangman->guessed, 0, PREG_SPLIT_NO_EMPTY);
                sort($guessed);
                $hangman->guessed = implode($guessed);
                $hangman->save();
                $win = $this->assessWin($hangman->guessed, $hangman->title);
            }
            else {
                $message .= 'Invalid guess. Please enter a single letter that hasn't already been guessed.';
            }
        }
    }
    $guessed = array();
    foreach (preg_split('//', $hangman->guessed, 0, PREG_SPLIT_NO_EMPTY) as $letter) {
        $guessed[$letter] = 1;
    }
    $maskedTitle = '';
    foreach (preg_split('//', $title, 0, PREG_SPLIT_NO_EMPTY) as $letter) {
        if (!isset($guessed[$letter]) && ctype_alpha($letter)) {
            $maskedTitle .= '_ ';
        }
        else {
            $maskedTitle .= $letter . ' ';
        }
    }
    $maskedTitle = preg_replace('/ /', '&nbsp;', $maskedTitle);
    
    $this->render('play', array('maskedTitle' => $maskedTitle,
        'guessed' => $hangman->guessed, 'fails' => $hangman->fails,
        'win' => $win, 'lose' => $lose, 'title' => $title,
        'token' => $hangman->token, 'message' => $message)); 
}
  1. Make one final change to the view, so we can see what we have done.
<?php
$this->breadcrumbs=array(
    'Hangman'=>array('/hangman'),
    'Play',
);
if (isset($error)) { 
    echo("<h1><center><u>CBDB Hangman Error</u></center></h1><br />\n");
    echo("<h2>Error: $error</h2><br />\n");
}
else {
    echo("<h1><center><u>Welcome to CBDB Hangman</u></center></h1><br />\n");
    echo("Title: $maskedTitle<br /><br />\n"); 
    echo("Guessed: $guessed<br />\n"); 
    echo("Fails: $fails<br />\n");
    if ($win) {
        echo("<br /><br /><center><h1>You Win!!!</h1></center><br />\n");
    }
    elseif ($lose) {
        echo("<br /><br /><center><h1>You Lose :(</h1></center><br />\n");
        echo("<h2>The answer was $title.</h2><br />\n");
    }
}

Now, you can actually play Hangman by appending guesses to the parameter string like this:

http://localhost/cbdb/index.php/hangman/play?token=85eab6e8ba42169fbf7c8c72355&guess=r

You will see something like the following screenshot as you guess letters:

If you repeat a guess, it has absolutely no effect. If you pass more than one letter, or a non-alphabetical character, the guess is not processed. Regardless of whether you enter a lowercase letter, or uppercase letter, the letter is uppercased and processed. If you guess a letter that is not in the title, $fails increments (which is displayed in the view). you can see that this one is GREEN LANTERN, so you can finish the title and make sure that a win is detected:

Let's start a new game to test losing. Once you get six failures, a loss is detected. Six failures to a loss is based on these body parts for the hangman: head, torso, left arm, right arm, left leg, and right leg. When a loss is detected, this is what happens:

If a particular game is played to a loss or a win, you can see the final state of the game by going to the play URL with the token, but you cannot change it.

Objective Complete - Mini Debriefing

We covered a lot of ground in this task. We are going to go over the final version of the controller and explain each piece of what we have accomplished.

The function assessWin() determines if a game has been won by checking to see if all the letters have been guessed, and returns true for a win or false otherwise. We added the following code snippet to handle the case where the token is not passed at all.

if (!isset($_GET['token'])) {
    $this->errorAndEnd('play', 'No token set.');
}

Next, logic was added to check for a loss, check for a win, process a guess, and then check for a win again.

When we process the guess, we validate it to make sure it is correct and hasn't already been guessed.

if (!$win && isset($_GET['guess'])) {  
    $guess = strtoupper($_GET['guess']);
    if (strlen($guess) == 1 && ctype_alpha($guess) && !strstr($hangman->guessed, $guess))  {
        if (!strstr($title, $guess)) {
            $hangman->fails++;
            if ($hangman->fails > 5) {
                $lose = true;
            }
        }
        $hangman->guessed .= $guess;
        $guessed = preg_split('//', $hangman->guessed, 0, PREG_SPLIT_NO_EMPTY);
        sort($guessed);
        $hangman->guessed = implode($guessed);
        $hangman->save();
        $win = $this->assessWin($hangman->guessed, $hangman->title);
    }
    else {
        $message .= 'Invalid guess. Please enter a single letter that hasn't already been guessed.';
    }
}

If the guess is not in the title, we increment $hangman->fails and check for a loss. We append the guess to $guessed, sort the letters in $guessed alphabetically, and reassign the string to $hangman->guessed. Then we call $hangman->save() to save $hangman->guessed (and $hangman->fails if needed). Then, we check for a win one final time.

The final call to $this->render() is modified to return the values the view will need. The view checks for an error, and if there is no error, displays relevant info about the game.

Developing the View

We've coded all the rules in the play and create actions in our controller, but our view doesn't really look like hangman. Also, guessing the letter by modifying the query string isn't really the most intuitive of user interfaces. However, at this point, we have tested all the functionality of the controller, and it seems solid, so we have a good foundation to start work on the final layer. Let's get to work on the view!

Prepare for Lift Off

We have produced some artwork to help us with this step. The images can be found in the images/hangman directory of our webroot. If you want, you can take a look at them before we get started, so you can see how this will all fit together.

Engage Thrusters

All the work done in this task will be in the view for play.

  1. Start with protected/views/play.php as follows:
<?php
  $this->breadcrumbs=array(
    'Hangman'=>array('/hangman'),
    'Play',
  );
 
  if (isset($error)) { 
    echo("<h1><center><u>CBDB Hangman Error</u></center></h1><br />\n");
    echo("<h2>Error: $error</h2><br />\n");
  }
  else {
    echo("<h1><center><u>Welcome to CBDB Hangman</u></center></h1><br />\n");
    echo("<p>\n");
    echo("Title: $maskedTitle<br /><br />\n"); 
    echo("Fails: $fails<br />\n"); 
    echo("Guessed: $guessed<br />\n"); 
    if ($win) {
      echo("<br /><br /><center><h1>You Win!!!</h1></center><br />\n"); 
    }
    elseif ($lose) {
      echo("<br /><br /><center><h1>You Lose :(</h1></center><br />\n"); 
      echo("<h2>The answer was $title.</h2><br />\n"); 
    }
    echo("</p>\n");
  }
?>
  1. Now let's incorporate those images I was showing you, to get started. Below the title Welcome to CBDB Hangman, place the following line:
echo("<h1><center><u>Welcome to CBDB Hangman</u></center></h1><br />\n");
echo("<img src='" . Yii::app()->request->baseUrl . "/images/hangman/hangman" . $fails . ".png'/><br />\n");
echo("<p>\n");
echo("Title: $maskedTitle<br /><br />\n");

Let's look at what that one line got us:

  1. It would be nicer if we could move the image to the right and display the interactive text to the left.
<?php
  $this->breadcrumbs=array(
    'Hangman'=>array('/hangman'),
    'Play',
  );
 
  if (isset($error)) { 
    echo("<h1><center><u>CBDB Hangman Error</u></center></h1><br />\n");
    echo("<h2>Error: $error</h2><br />\n");
  }
  else {
?>
<style type="text/css">
. floatright {
  float: right;
  margin: 0 0 10px 10px;
}
</style>
<h1><center><u>Welcome to CBDB Hangman</u></center></h1><br />
<p>
<?php
    echo("<div class='floatright'>\n");
    echo("<img src='" . Yii::app()->request->baseUrl . "/images/hangman/hangman" . $fails . ".png'/><br />\n");
    echo("</div>\n");
 
    echo("Title: $maskedTitle<br /><br />\n"); 
    echo("Fails: $fails<br />\n"); 
    echo("Guessed: $guessed<br />\n"); 
    if ($win) {
      echo("<br /><br /><center><h1>You Win!!!</h1></center><br />\n"); 
    }
    elseif ($lose) {
      echo("<br /><br /><center><h1>You Lose :(</h1></center><br />\n"); 
      echo("<h2>The answer was $title.</h2><br />\n"); 
    }
    echo("</p>\n");
  }
?>
  1. Let's make it easy to start a new game, and make it easy to go to the index page. Add the following two lines after the image tag and then throw in some <h2> tags:
echo("<div class='floatright'>\n");
echo("<img src='" . Yii::app()->request->baseUrl . "/images/hangman/hangman" . $fails . ".png'/><br />\n");
echo("<center><a href='" . Yii::app()->request->baseUrl . "/index.php/hangman/create'>New Game</a><br />");
echo("<a href='" . Yii::app()->request->baseUrl . "/index.php'>Back to CBDB</a><br /></center>");
echo("</div>\n");
 
echo("<h2>\n");
echo("Title: $maskedTitle<br /><br />\n"); 
echo("Fails: $fails<br />\n"); 
echo("Guessed: $guessed<br />\n"); 
echo("</h2>\n");

Now it should look like the following screenshot:

This gives us almost everything we need, with just a few lines of code. The only thing we still need is a way to make guesses, other than manually modifying parameters in the query string. It should be fairly straightforward to make a tiny form that simply submits the guess.

  1. Make the final version of your view look like the following code snippet (We moved some things around to make it prettier, and added the aforementioned form), and we'll discuss what we've done:
<?php
  $this->breadcrumbs=array(
    'Hangman'=>array('/hangman'),
    'Play',
  );
 
  if (isset($error)) { 
    echo("<h1><center><u>CBDB Hangman Error</u></center></h1><br />\n");
    echo("<h2>Error: $error</h2><br />\n");
  }
  else {
?>
<style type="text/css">
. floatright {
  float: right;
  margin: 0 0 10px 10px;
}
 
.errormessage {
  color: red;
}
</style>
<h1><center><u>Welcome to CBDB Hangman</u></center></h1><br />
<p>
<?php
    echo("<div class='floatright'>\n");
    echo("<img src='" . Yii::app()->request->baseUrl . "/images/hangman/hangman" . $fails . ".png'/><br />\n");
    echo("<center><a href='" . Yii::app()->request->baseUrl . "/index.php/hangman/create'>New Game</a><br />"); 
    echo("<a href='" . Yii::app()->request->baseUrl . "/index.php'>Back to CBDB</a><br /></center>"); 
    echo("</div>\n");
 
    echo("<h2>\n"); 
    echo("Title: $maskedTitle<br /><br />\n"); 
    echo("Fails: $fails<br />\n"); 
    echo("Guessed: $guessed<br />\n"); 
    echo("</h2>\n"); 
    if ($win) {
      echo("<br /><br /><center><h1>You Win!!!</h1></center><br />\n"); 
    }
    elseif ($lose) {
      echo("<br /><br /><center><h1>You Lose :(</h1></center><br />\n"); 
      echo("<h2>The answer was $title.</h2><br />\n"); 
    }
    else {
?>
      <form name="guess_form" method="get">
      <?php echo("<input type='hidden' name='token'alue='$token'>\n"); ?>
      Guess: <input type="text" name="guess" />
      <input type="submit" value="Submit" />
      </form> 
<?php
      echo("<div class='errormessage'>\n");
      echo("$message<br />\n");
      echo("</div>\n");
    }
  }
?>

This leaves us here:

If we put in an invalid guess, this is what happens:

Objective Complete - Mini Debriefing

What we did in this task is all pretty straightforward.

We created some divs and used some style tricks to make it all prettier. Ultimately, our style changes should be placed in css files so it is easier to include and maintain them.

When we added the form that submits the guess, we went ahead and added a token as a hidden input field, built to have the value of $token, so it will resubmit the token value with the guess. Because of our conditional logic, the form will not be displayed if there is an error, or if the game has already been won or lost.

This works pretty well, but we could make it even better without too much effort. In the next task, we will look at how we can improve the view.

Improving the View

There are a few mildly annoying problems with the game as we have currently implemented it. You have to click on the Guess input box to type, and then you can click on Submit or press Enter. It would be nice if we could set the input focus to the Guess input box. It would also be nice if we didn't have to press Enter, since we are only guessing one letter at a time. We can use jQuery to quickly fix this.

Engage Thrusters

Let's give the Guess text input focus when the page loads. Register a tiny piece of JavaScript to be run on POS_READY, and then give the Guess input an ID to refer to it from the JavaScript. We will use jQuery to do this because it is convenient and concise (open protected/views/hangman/play.php):

  else {
    Yii::app()->clientScript->registerScript('guessfocus',"
      $('#guess_id').focus();
    ",CClientScript::POS_READY);
?>
 
    <form name="guess_form" method="get">
      <?php echo("<input type='hidden' name='token' value='$token'>\n"); ?>
      Guess: <input type="text" name="guess" id='guess_id'/>
      <input type="submit" value="Submit" />
    </form> 
    <?php
      echo("<div class='errormessage'>\n");
      echo("$message<br />\n");
      echo("</div>\n");
    }
  }
?>

That was easy! Now when you load the page, the Guess textbox has the input focus.

We put the registerScript() statement inside the else clause so it will only run if the form is displayed.

Let's submit the form when we press a key. jQuery has a keyPress() function for hooking the event, but they warn us that it is not part of their official API and that it may work differently for different values. We will use it and keep the code simple and straightforward to avoid cross-browser issues:

  else {
    Yii::app()->clientScript->registerScript('guessfocus',"
      $('#guess_id').focus();
      $('#guess_id').keypress(function(event) {
        if ((event.ctrlKey == false) &&
          (event.altKey == false) &&
          (event.metaKey == false)) {
       
            event.preventDefault();
            $('#guess_id').val(
            String.fromCharCode(event.charCode));
            $('#guess_form_id').submit();
           
          }
        });
        ",CClientScript::POS_READY);
?>
 
      <form name="guess_form" id="guess_form_id" method="get">
        <?php echo("<input type='hidden' name='token' value='$token'>\n"); ?>
        Guess: <input type="text" name="guess" id='guess_id'/>
        <input type="submit" value="Submit" />
      </form> 
<?php
      echo("<div class='errormessage'>\n");
      echo("$message<br />\n");
      echo("</div>\n");
    }
  }
?>

Objective Complete - Mini Debriefing

With asynchronous notification events like this, the sequence of events can be somewhat counter-intuitive. It would seem that we don't need the call preventDefault() or the call to val() to set the value of guess_id. If you remove those lines, the textbox will be updated with the character you pressed, but when the form automatically submits in the keyPress() function, the guess will not yet have a value. If you set the value for guess but don't call preventDefault(), two copies of the character you pressed will appear in the textbox. If you set the value and then return false from the keyPress() function, it appears to work as desired in the Firefox browser, but not necessarily in other browsers.

So now it auto-submits. We don't even need that Submit button. Just go ahead and delete it from your form, and enjoy playing hangman.

Authorized Entry Only

We have made a fully functional game. It should now be pointed out that we made it with Srbac in debug mode, and we should now lock it down so only logged in users can play. We don't want anyone to be able to go to the create URL page and arbitrarily create games and consume server resources. We are quickly going to walk through this, but it is covered fully in Project 4, Level Up! Permission Levels. You might want to refer back if you have questions.

Prepare for Lift Off

We have been developing in debug mode. Turn off debug mode in the srbac array in protected/config/main.php.

'srbac' => array(
    'userclass'=>'User', //default: User
    'userid'=>'id', //default: userid
    'username'=>'username', //default:username
    'delimeter'=>'@', //default:-
    //'debug'=>false, //default :false
    'pageSize'=>10, // default : 15
    'superUser' =>'Authority', //default: Authorizer

Engage Thrusters

As we didn't make any allowance in our JSON-fetching curl code to allow for authentication, we will need to allow anyone to get a title list. This means anyone that can see your site can get a list of all titles. In the You Ready to go Gung HO? A Hotshot Challenge section, fixing this is one of the challenges. Let's make it so anyone can access BookTitlelist right now, also in the srbac array in protected/config/main.php:

  'notAuthorizedView'=> 'application.views.srbac.access_denied', 
  'alwaysAllowed'=>array( 'SiteLogin','SiteLogout','SiteIndex', 'SiteError', 'BookTitlelist'),
  'userActions'=>array('Show','View','List'),

Now we will make some changes so that only users that are logged in can play the game (we don't really care which users, we just want to make sure they have a valid username and password). Before we do this, make sure you can't see the game. Go to http://localhost/cbdb/index.php/hangman/create while you're not logged in, and it should redirect you to the login screen. Log in as any user, and you should see the following screenshot:

We need to make a role for playing games and add it to all users. Go to the Srbac menu, click on Managing Auth Items, and then click on Autocreate Auth Items. Click on the lightbulb next to Hangman. Uncheck Create Tasks, check Check All, and then click on Create. It should give the following output:

Creating operations
'HangmanCreate' created successfully
'HangmanPlay' created successfully

Click on Managing Auth Items, then add a task named playGames. In the Description field, you can enter Allows users to play games. Next, create a role called gamer. Click on Assign to Users, then select Tasks. Select playGames and add the operations HangmanCreate and HangmanPlay. Then click on Roles, select Games, and add the task playGames. Srbac is somewhat limited, so we'll need to run the following SQL command in our database:

insert into auth_item_child VALUES ('wishlistAccess', 'gamer');

This puts games at the bottom of the role hierarchy, below wishlistAccess.

Now you should be able to get to the game only if you are logged in.

Objective Complete - Mini Debriefing

We turned debug mode off, added BookTitlelist to alwaysAllowed, created operations for each Hangman action, added them to a games role, and added the games role as a child of the wishlistAccess role.

Classified Intel

Each time a new game is created, a record is created in the Hangman table. A creation timestamp could be added, and a job could be scheduled that deletes records that are beyond a certain age. This could free up disk space and delete unnecessary database records. This will only be a serious issue if you have lots of users playing the game, or if someone is conducting an attack against your site by creating games.

Reusing Code – Making a New Game

Code reusability and maintainability are often touted as two of the most important aspects of corporate software development. Object-oriented programming and MVC frameworks have acquired a great deal of popularity due to the ease of reusing and maintaining code that uses these methodologies. We will make a new game, where an author is given, and you pick the comic book they wrote. We will reuse a lot of the code we wrote in this task so far, and so we should be able to quickly cobble together a new game.

Prepare for Lift Off

The Hangman database table can be repurposed as a general table for both games, but it is obviously now misnamed and will also need some minor changes to be suitable for both games. Let's create a new suitable table and drop the other table (if we were in a production situation where the data was valuable, you could dump the data, make some changes, and reimport it to the new table). We will also create a game_type table to store different kinds of games. We need to run four database commands.

CREATE TABLE `game_type` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `devname` varchar(20), 
  `name` varchar(40),
  PRIMARY KEY (`id`)
) ENGINE=InnoDB;
 
INSERT INTO `game_type` VALUES (0,'hangman', 'Hangman'),(0,'wrote_it','Wrote It');
 
CREATE TABLE `game` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `target` varchar(80) NOT NULL DEFAULT '',
  `guessed` varchar(26) NOT NULL DEFAULT '',
  `fails` tinyint(3) unsigned DEFAULT '0',
  `token` varchar(64) NOT NULL,
  `game_type_id` int(10) unsigned NOT NULL,
  PRIMARY KEY (`id`),
  FOREIGN KEY (`game_type_id`) REFERENCES `game_type` (`id`),
  UNIQUE (`token`)
) ENGINE=InnoDB;
 
DROP TABLE `hangman`;

Engage Thrusters

Now that we have our newly adjusted and created tables, we have to make the Hangman game work with the changes. We can just generate new models, and then make the changes in our controller. We are going to have one table, one model, and two controllers with a great deal of shared functionality. Let's get started. Generate the models with Gii. Generate a model named Gametype for the game_type table, and a model named Game for the game table. Make sure Build Relations is checked.

Now it's time to start making changes to our controller. Create a file named GameController.php in protected/components and move the functions hangmanToken() and errorAndEnd() from protected/controllers/HangmanController.php and then make the changes shown as follows:

<?php
 
class GameController extends Controller
{
  protected function gameToken() {
    $charset = '0123456789abcdef';
    $token = '';
    $charArr = preg_split('//', $charset, 0, PREG_SPLIT_NO_EMPTY);
    for ($count = 0; $count < 32; $count++) {
      $token .= $charArr[mt_rand(0, count($charArr) - 1)];
    }
    return $token;
  }
 
  protected function errorAndEnd($action, $error) {
    $this->render($action, array('error' => $error));
    Yii::app()->end();
  }
}

Now let's open the original HangmanController.php. Update it to reflect the current changes.

<?php
 
class HangmanController extends GameController
{
  private function assessWin($guesses, $title) {
    $guessArr = array();
    foreach (preg_split('//', strtoupper($guesses), 0, PREG_SPLIT_NO_EMPTY) as $letter) {
      $guessArr[$letter] = true;  
    }
    foreach (preg_split('//', strtoupper($title), 0, PREG_SPLIT_NO_EMPTY) as $letter) {
      if (!isset($guessArr[$letter]) && ctype_alpha($letter)) {
        return false;
      }
    }
    return true;
  }
 
  public function actionCreate()
  {
    $error = '';
    $request = Yii::app()->request;
    $jsonUrl = $request->hostInfo . $request->baseUrl . '/index.php/book/titlelist';
    $ch = curl_init($jsonUrl);
    $options = array(
      CURLOPT_RETURNTRANSFER => true,
      CURLOPT_HTTPHEADER => array('Content-type: application/json')
    );
    curl_setopt_array( $ch, $options);  
    $titles = json_decode(curl_exec($ch));
    curl_close($ch);
    if ((!is_array($titles)) || (count($titles) == 0)) {
      $this->errorAndEnd('create', 'No titles found fetching from URL: ' . $jsonUrl);
    }
    for ($count = 0; $count < count($titles); $count++) {
      if (strlen($titles[$count]) < 8) {
        unset($titles[$count]);
      }
    }
    if (count($titles) < 1) {
      $this->errorAndEnd('create', 'No suitable titles found in database.');
    }
    $titles = array_merge($titles);  //Renumber the array
 
    $gameType = Gametype::model()->find('devname="hangman"');
    
    $hangman = new Game;
    $randCount = 0;
    $hangman->target = strtoupper($titles[mt_rand(0, count($titles) - 1)]);
    do {
      if ($randCount > 5) {  //Even one duplicate is *highly* unlikey (1 in 2^128 if mt_rand were truly random)
        $this->errorAndEnd('create', 'Token generation appears to be broken.');
      }
      $hangman->token = $this->gameToken();
      $randCount++;
    } while ((Game::model()->find('token=:token', array(':token'=>$hangman->token))) != null);
    $hangman->game_type_id = $gameType->id;
    $hangman->save();
    $this->redirect($request->hostInfo . $request->baseUrl . '/index.php/hangman/play?token=' . $hangman->token);
  }
 
  public function actionPlay()
  {
    if (!isset($_GET['token'])) {
      $this->errorAndEnd('play', 'No token set.');
    }
 
    $game_type = Gametype::model()->find('devname="hangman"');
 
    $token = $_GET['token'];
    $hangman = Game::model()->find('token=:token', array(':token'=>$token));
    if (($hangman == null)|| ($hangman->game_type_id != $game_type->id)) {
      $this->errorAndEnd('play', 'Invalid token.');
    }
    $message = '';
    
    $title = strtoupper($hangman->target);
    $win = false;
    $lose = false;
    if ($hangman->fails > 5) {
      $lose = true;
    }
    else {
      $win = $this->assessWin($hangman->guessed, $hangman->target);
      if (!$win && isset($_GET['guess'])) {  
        $guess = strtoupper($_GET['guess']);
        if (strlen($guess) == 1 && ctype_alpha($guess) && !strstr($hangman->guessed, $guess))  {
          if (!strstr($title, $guess)) {
            $hangman->fails++;
            if ($hangman->fails > 5) {
              $lose = true;
            }
          }
          $hangman->guessed .= $guess;
          $guessed = preg_split('//', $hangman->guessed, 0, PREG_SPLIT_NO_EMPTY);
          sort($guessed);
          $hangman->guessed = implode($guessed);
          $hangman->save();
          $win = $this->assessWin($hangman->guessed, $hangman->target);
        }
        else {
          $message .= 'Invalid guess. Please enter a single letter ' . 
            'that hasn\'t been guessed before.';
        }
        
      }
    }
    $guessed = array();
    foreach (preg_split('//', $hangman->guessed, 0, PREG_SPLIT_NO_EMPTY) as $letter) {
      $guessed[$letter] = 1;
    }
    $maskedTitle = '';
    foreach (preg_split('//', $title, 0, PREG_SPLIT_NO_EMPTY) as $letter) {
      if (!isset($guessed[$letter]) && ctype_alpha($letter)) {
        $maskedTitle .= '_ ';
      }
      else {
        $maskedTitle .= $letter . ' ';
      }
    }
    $maskedTitle = preg_replace('/ /', '&nbsp;', $maskedTitle);
    
    $this->render('play', array('maskedTitle' => $maskedTitle, 
      'guessed' => $hangman->guessed, 'fails' => $hangman->fails, 
      'win' => $win, 'lose' => $lose, 'title' => $title, 
      'token' => $hangman->token, 'message' => $message));  
  }
}

At this point, Hangman should continue working the way it always has. We now have a GameController class that we can extend for additional games.

Let's discuss how our new game "Wrote It" will work. We will select an author and a book written by that author. We will then present the author, and the correct book intermixed with other books the author did not write in a dropdown. If the user picks the proper choice from the dropdown, they win the round. Otherwise, they lose.

  1. We need to find the common functionality this game will share with Hangman, and place that functionality in GameController. Move the code to GameController from HangmanController, generalizing and compartmentalizing it for reuse as you go.
<?php
 
class GameController extends Controller
{
    protected function gameToken() {
    $charset = '0123456789abcdef';
    $token = '';
    $charArr = preg_split('//', $charset, 0, PREG_SPLIT_NO_EMPTY);
    for ($count = 0; $count < 32; $count++) {
      $token .= $charArr[mt_rand(0, count($charArr) - 1)];
    }
    return $token;
  }
 
  protected function fullGameToken() {
    $randCount = 0;
    do {
      if ($randCount > 5) {  //Even one duplicate is *highly* unlikey (1 in 2^128 if mt_rand were truly random)
        $this->errorAndEnd('create', 'Token generation appears to be broken.');
      }
      $token = $this->gameToken();
      $randCount++;
    } while ((Game::model()->find('token=:token', array(':token'=>$token))) != null);
    return $token;
 
  }
 
  protected function evalTokenAndGetGame($gameTypeDevname) {
    if (!isset($_GET['token'])) {
      $this->errorAndEnd('play', 'No token set.');
    }
 
    $gameType = Gametype::model()->find('devname=:devname', array('devname' => $gameTypeDevname));
 
    $token = $_GET['token'];
    $game = Game::model()->find('token=:token', array(':token'=>$token));
    if (($game == null)|| ($game->game_type_id != $gameType->id)) {
      $this->errorAndEnd('play', 'Invalid token.');
    }
    return $game;
  }
 
  protected function errorAndEnd($action, $error) {
    $this->render($action, array('error' => $error));
    Yii::app()->end();
  }
 
  protected function getAllTitles($request) {
    $jsonUrl = $request->hostInfo . $request->baseUrl . '/index.php/book/titlelist';
    $ch = curl_init($jsonUrl);
    $options = array(
      CURLOPT_RETURNTRANSFER => true,
      CURLOPT_HTTPHEADER => array('Content-type: application/json')
    );
    curl_setopt_array( $ch, $options);  
    $titles = json_decode(curl_exec($ch));
    curl_close($ch);
    if ((!is_array($titles)) || (count($titles) == 0)) {
      $this->errorAndEnd('create', 'No titles found fetching from URL: ' . $jsonUrl);
    }
    return $titles;
  }
}
  1. Now change protected/controllers/HangmanController.php to reflect the new changes.
<?php
 
class HangmanController extends GameController
{
  private function assessWin($guesses, $title) {
    $guessArr = array();
    foreach (preg_split('//', strtoupper($guesses), 0, PREG_SPLIT_NO_EMPTY) as $letter) {
      $guessArr[$letter] = true;  
    }
    foreach (preg_split('//', strtoupper($title), 0, PREG_SPLIT_NO_EMPTY) as $letter) {
      if (!isset($guessArr[$letter]) && ctype_alpha($letter)) {
        return false;
      }
    }
    return true;
  }
 
  public function actionCreate()
  {
    $error = '';
    $request = Yii::app()->request;
    $titles = $this->getAllTitles($request);
    
    for ($count = 0; $count < count($titles); $count++) {
      if (strlen($titles[$count]) < 8) {
        unset($titles[$count]);
      }
    }
    if (count($titles) < 1) {
      $this->errorAndEnd('create', 'No suitable titles found in database.');
    }
    $titles = array_merge($titles);  //Renumber the array
 
    $game_type = Gametype::model()->find('devname="hangman"');
    
    $hangman = new Game;
    $hangman->target = strtoupper($titles[mt_rand(0, count($titles) - 1)]);
    $hangman->token = $this->fullGameToken();
    $hangman->game_type_id = $game_type->id;
    $hangman->save();
    $this->redirect($request->hostInfo . $request->baseUrl . '/index.php/hangman/play?token=' . $hangman->token);
  }
 
  public function actionPlay()
  {
    $hangman = $this->evalTokenAndGetGame('hangman'); 
    $message = '';
    
    $title = strtoupper($hangman->target);

Once again, if all has gone well, the Hangman game should now be working in exactly the same way it always has. The code still remaining in HangmanController.php is now all specific to Hangman, and generalized code that Hangman will share with the game we are about to write is now in GameController.php.

Let's get to work on our new game. We need to add a few additional fields to our database table now that we know where we are going. We will also need to regenerate the model.

  1. Run the following statements for your database:
ALTER TABLE `game` ADD COLUMN `book_decoy3_id` int(10) unsigned DEFAULT NULL AFTER `guessed`;
ALTER TABLE `game` ADD COLUMN `book_decoy2_id` int(10) unsigned DEFAULT NULL AFTER `guessed`;
ALTER TABLE `game` ADD COLUMN `book_decoy1_id` int(10) unsigned DEFAULT NULL AFTER `guessed`;
ALTER TABLE `game` ADD COLUMN `author_id` int(10) unsigned DEFAULT NULL AFTER `guessed`;
ALTER TABLE `game` ADD COLUMN `win` BOOLEAN DEFAULT 0;
ALTER TABLE `game` ADD COLUMN `book_id` int(10) unsigned DEFAULT NULL AFTER `guessed`;
ALTER TABLE `game` ADD FOREIGN KEY (`book_id`) REFERENCES `book` (`id`);;
ALTER TABLE `game` ADD FOREIGN KEY (`author_id`) REFERENCES `person` (`id`);
ALTER TABLE `game` ADD FOREIGN KEY (`book_decoy1_id`) REFERENCES `book` (`id`);
ALTER TABLE `game` ADD FOREIGN KEY (`book_decoy2_id`) REFERENCES `book` (`id`);
ALTER TABLE `game` ADD FOREIGN KEY (`book_decoy3_id`) REFERENCES `book` (`id`);

Gii won't generate code if the file already exists. Delete the current Game model.

rm protected/models/Game.php
  1. Now generate the model with Gii:
Generating code using template "/opt/lampp/htdocs/yii-1.1.10.r3566/framework/gii/generators/model/templates/default"...
 generated models/Game.php
done!
  1. Go to the Gii Controller Generator and generate a controller named Wroteit with a base class of GameController and the actions create, play, and index.
Generating code using template "/opt/lampp/htdocs/yii-1.1.10.r3566/framework/gii/generators/controller/templates/default"...
 generated controllers/WroteitController.php
 generated views/wroteit/create.php
 generated views/wroteit/index.php
 generated views/wroteit/play.php
done!
  1. We need a new relationship in the Person model as well (protected/models/Person.php) so we can easily determine which books a particular author has written. Add the following relationship:
public function relations()
{
    // NOTE: you may need to adjust the relation name and //the related
    // class name for the relations automatically //generated below.
    return array(
      'books' => array(self::MANY_MANY, 'Book',
        'bookauthor(book_id, author_id)',
        'index'=>'id'),
      'bookauthors' => array(self::HAS_MANY, 'Bookauthor', 'author_id'),
      'bookillustrators' => array(self::HAS_MANY, 'Bookillustrator', 'illustrator_id'),
    );
}
  1. We need to update the controller for create and update, the way we did for Hangman (protected/controllers/WroteitController.php).
<?php
 
class WroteitController extends GameController
{
  private function selectRandomAuthorWithBook($action) {
    $bookauthors = BookAuthor::model()->findAll(array(
      'select'=>'author_id',
      'group'=>'author_id',
      'distinct'=>true,
    ));
    $authorIds = array();
    foreach ($bookauthors as $bookauthor) {
      $authorIds[] = $bookauthor['author_id'];
    }
    if (count($authorIds) == 0) {
      $this->errorAndEnd($action, 'No authors in database.');
    }
    $author = Person::model()->find('id=:id', array('id'=>$authorIds[mt_rand(0, count($authorIds) - 1)]));
    $bookIds = array();
    foreach ($author->books as $book) {
      $bookIds[] = $book['id'];
    }
    if (count($bookIds) == 0) {
      $this->errorAndEnd($action, 'Relational integrity error.  You should not see this.');
    }
    $bookIndex = mt_rand(0, count($bookIds) - 1);
    return array(
      'author_id' => $author['id'],
      'book_id' => $bookIds[mt_rand(0, count($bookIds) - 1)], 
    );
  }
 
  //This function will return three books that are not //written by the author referenced by author_id
  private function selectThreeSuitableBooks($author_id, $action) {
    $author = Person::model()->find('id=:id', array('id'=>$author_id));
    $bookIdsByAuthor = array();
    foreach ($author->books as $book) {
      $bookIdsByAuthor[] = $book['id'];
    }
    $criteria = new CDbCriteria;
    $criteria->addNotInCondition('id', $bookIdsByAuthor);
    $ret = array();
    $books = Book::model()->findAll($criteria);
    $bookIds = array();  
    foreach ($books as $book) {
      $bookIds[] = $book['id'];
    }
    if (count($bookIds) < 3) {
      $this->errorAndEnd($action, 'Not enough books not written by author in database.');
    }
    elseif (count($bookIds) == 3) {
      return $bookIds;
    }
    else {
      for ($count = 0; $count < 3; $count++) {
        $index = mt_rand(0, count($bookIds) - 1);
        $ret[] = $bookIds[$index];  
        unset($bookIds[$index]);
        $bookIds = array_merge($bookIds);
      }
    }
    return $ret;
  }
 
  public function actionCreate()
  {
    $randbook = $this->selectRandomAuthorWithBook('create');
    $decoyIds = $this->selectThreeSuitableBooks($randbook['author_id'], 'create');
    $gameType = Gametype::model()->find('devname="wrote_it"');
    $wroteIt = new Game;
    $wroteIt->token = $this->fullGameToken();
    $wroteIt->game_type_id = $gameType['id'];
    $wroteIt->book_id = $randbook['book_id'];
    $wroteIt->author_id = $randbook['author_id'];
    $wroteIt->book_decoy1_id = $decoyIds[0];
    $wroteIt->book_decoy2_id = $decoyIds[1];
    $wroteIt->book_decoy3_id = $decoyIds[2];
    $wroteIt->save();
    $request = Yii::app()->request;
    $this->redirect($request->hostInfo . $request->baseUrl . '/index.php/wroteit/play?token=' . $wroteIt->token);
  }
 
  public function actionIndex()
  {
    $this->render('index');
  }
 
  public function actionPlay()
  {
    $wroteIt = $this->evalTokenAndGetGame('wrote_it');  
    $win = false;
    if (!$wroteIt->win && $wroteIt->fails == 0 && isset($_GET['guess'])) {
      $guess = $_GET['guess'];
      if (strlen($guess) != 0) {
        if ($guess != $wroteIt->book_id) {
          $wroteIt->fails++;  
        }
        else {
          $win = true;
          $wroteIt->win = true;
        }
        $wroteIt->save();
      }
    }
    elseif ($wroteIt->win) {
      $win = true;
    }
    $ids = array(
      $wroteIt->book_id, $wroteIt->book_decoy1_id,
      $wroteIt->book_decoy2_id, $wroteIt->book_decoy3_id
    );
    $criteria = new CDbCriteria;
    $criteria->addInCondition('id', $ids);
    $choices = array();
    $author = Person::model()->find('id=:id', array('id' => $wroteIt->author_id));
    $books = Book::model()->findAll($criteria);
    shuffle($books);
    foreach ($books as $book) {
      $choices[] = array('id' => $book->id, 'title' => $book->title);
    }
    $answer = '';
    $lose = false;
    if ($wroteIt->fails > 0) {
      $lose = true;
    }
    if ($win || $lose) {
      $bookAnswer = Book::model()->find('id=:id', array('id' => $wroteIt->book_id));
      $answer = $bookAnswer->title;  
    }
    
    $this->render('play', array('choices' => $choices, 
      'author' => $author['fname'] . ' ' . $author['lname'],
      'win' => $win, 'lose' => $lose, 'token' => $wroteIt->token,
      'answer' => $answer)
    );
  }
}
  1. Make a simple fall-back view for create (protected/views/wroteit/create.php).
<?php
$this->breadcrumbs=array(
  'Wroteit'=>array('/wroteit'),
  'Create',
);
?>
 
<h1>WroteIt Game Start Error</h1>
<pre>
<?php if (isset($error)) {echo("ERROR: $error <br />\n");} ?>
</pre>
  1. Now create a view for play (protected/views/wroteit/play.php).
<?php
  $this->breadcrumbs=array(
    'Wroteit'=>array('/wroteit'),
    'Play',
  );
 
  
  if (isset($error)) { 
    echo("<h1><center><u>CBDB WroteIt Error</u></center></h1><br />\n");
    echo("<h2>Error: $error</h2><br />\n");
  }
  else { //no $error
?>
<h1><center><u>Welcome to CBDB WroteIt</u></center></h1><br />
<?php
    if ($win) {
      echo("<center><h1>You Win!!!</h1><br />\n"); 
      echo("<h2>$author wrote $answer.</h2></center><br />\n"); 
    }
    elseif ($lose) {
      echo("<center><h1>You Lose :(</h1><br />\n"); 
      echo("<h2>$author wrote $answer.</h2></center><br />\n"); 
    }
    else { //no win or lose
      echo("<center><h2>Author: $author</h2></center><br />");
?>
<form name="guess_form" id="guess_form_id" method="get">
<?php echo("<input name='token' type='hidden' value='$token'>\n");?>
<center>
What did this author write?
<select name='guess'>
  <option value="" style="display:none;"></option>
<?php
  foreach($choices as $choice) {
    echo('<option value="' . $choice['id'] . '">');
    echo($choice['title']);
    echo("</option>\n");
  }
?>
</select>
<input type="submit" value="Submit">
</center>
</form>
<?php
    } //no win or lose
  } //no $error
  echo("<center><a href='" . Yii::app()->request->baseUrl . "/index.php/wroteit/create'>New Game</a><br />"); 
  echo("<a href='" . Yii::app()->request->baseUrl . "/index.php'>Back to CBDB</a><br /></center>"); 
?>
  1. We need to set up permissions for users to run the app as well. Go into the Srbac menu as the admin user and auto-create the operations for Wroteit. Then, add the operations to the playGames role. At this point, we have two fully functional games.

Wroteit should look like the following screenshot:

Objective Complete - Mini Debriefing

We already developed one game, so the contents of our new controller and view should look pretty familiar. When we wrote them, we followed the same order of operations we did for Hangman. We generated the model(s), and then developed and tested the controller with minimal views, and then focused on the views. For the sake of avoiding repetition, we did not walk through every step this time. We mainly wanted to focus on the process of finding common functionality and moving it into a common base class, and then using the base class to make something new.

Mission Accomplished

We made a Hangman game, maintained it with the intention of reusing basic functional components to make a new game, and then made the new game named WroteIt. We did a lot for one chapter. It's pretty fun!

You Ready to go Gung HO? A Hotshot Challenge

On Linux systems, /dev/random and /dev/urandom are typically the best sources of random numbers. System entropy is typically incorporated into these devices to add entropy to the generated output. The /dev/random device is blocking, which means if the system does not contain enough entropy for it to generate the required number of bits, it will wait until enough system entropy is available (based on the general "busyness" of the system) to generate the number. The /dev/urandom device will take whatever system entropy is available and generate the remaining number of bits via other pseudorandom means. The challenge is to write your own PHP function that uses one of these devices to generate our token. Experiment with how busy the system has to be to use /dev/random rather than /dev/urandom. You can also use the function open_random_pseudo_bytes()(especially if you need a cross-platform solution). See the PHP documentation available at http://php.net/manual/en/function.openssl-random-pseudo-bytes.php for details.

In addition to improving the security of the token, there are other projects we could try. Putting everything in the same database table was a little kludgy. To do it properly, we should have one common game table with the token and perhaps win and lose (the things common between the games) and then we should have two other tables for Hangman and WroteIt that relate to the Games table with a foreign key. The game WroteIt could be reworked to be more exciting in a variety of ways, such as serving a large number of rounds to the user and tracking their performance.

评论 X

      友荐云推荐
      Copyright 2011-2014. YiiBook.com