PHP - Interfict and SQL

From LXF Wiki

Table of contents

Practical PHP

(Original version written by Paul Hudson for LXF issue 59.)


Creating an adventure by hand is no easy task, which is why we do it for you...


Fresh back from OSCon, it's now clear that PHP 5.1 is a lot closer than I previously thought. For some reason I had the crazy idea that after all the work that was put into PHP 5.0 there would be a sensible time lapse between it and the next major release so that work could be done on any bugs that were found.

Instead, PHP 5.0 turns out to be remarkably thin on bugs, which means the majority of development work is already going into the next big release, 5.1. The main area for development may well come as a surprise: it's all about speed. Now, PHP 4 and 5 are fast enough, in my opinion - particularly when used with a code cache - but there's no reason to say it can't be faster. Many people are making the move from Apache 1.3 to Apache 2.0 in the hope it will boost the performance of their site, only to find themselves disappointed: PHP is usually the biggest bottleneck. There's talk afoot of speed-ups between 30-50% in PHP 5.1, which is great news for all of us.

The other thing that's new - and which is why I'm mentioning PHP 5.1 at all - is called PDO, short for PHP Data Objects. This is a unification of the various PHP database APIs so that there is no longer any need for a myriad of functions. It's important to understand that this is not the same as PEAR::DB, which is an abstraction layer that allows you to write generic SQL statements that work on any SQL server. The current plan is to allow you to use PDO and PEAR::DB side by side: the former as an API wrapper and the latter as SQL abstraction. It will make more sense once you start using it, honest!

Anyway, PDO will pretty much redefine how the database code for our system will work, which figures. However, it won't be in general use for six months yet, so I wouldn't worry about it: just be aware that it's on the horizon.

This month we're going to be putting some simple data into our tables to get an adventure off the ground. We'll also be start work on the game engine as we go: hopefully we'll be able to implement the SQL and PHP in tandem so as to keep your mind fully occupied!


Creating a game

The first task we're going to work on is being able to have a character stand in a room. Working back from "character" you'll see we also need to define some classes and races, as well as at least one user. Working back from "room" you'll see we need a game entry also. As each character needs to be tied to a single game, and each game needs to be owned by a user, the very first thing we need to do is add a user to our database, like this:

INSERT INTO users (Username, Password, EmailAddress, DateJoined) VALUES ('SomeUser',
'SomePassword', 'foo@bar.com', UNIX_TIMESTAMP(now()));

One user is enough to create a game and also to create characters, so the next step is to create the main game entry with this SQL:

INSERT INTO games (Name, Creator, Difficulty, DateCreated, Info, MinCharLevel, PointsAdjust,
RandomEncounters, RandomEncounterStrength, Status, Length) VALUES ('Tux\'s Revenge', 1, 1,
UNIX_TIMESTAMP(now()), 'An interesting adventure!', 1, 0,  5, 1, 1, 1);

That sets us up a pretty standard adventure: easy difficulty, basic character level, no points adjustment, average random encounters, and set to "just created" (i.e. not currently playable). Before we're able to create a character, though, we need races and classes. If you recall, races and classes that have a -1 as their Game field are available in all games, so what we're going to do is set up these default classes and races and use them for this game.

Here's the SQL:

INSERT INTO races (Game, Name, Info, PointsAdjust, StrengthAdjust,
IntellectAdjust, EnergyAdjust, AgilityAdjust, VitalityMaxAdjust,
ManaMaxAdjust, HungerMaxAdjust, MinAge, MaxAge) VALUES (-1, 'Human',
'Generic race.', 0, 0, 0, 0, 0, 0, 0, 0, 18, 70);

INSERT INTO races (Game, Name, Info, PointsAdjust, StrengthAdjust,
IntellectAdjust, EnergyAdjust, AgilityAdjust, VitalityMaxAdjust,
ManaMaxAdjust, HungerMaxAdjust, MinAge, MaxAge) VALUES (-1, 'Elf', 'Tall
and weedy.', 0, -1, 1, -1, 2, 0, 50, 10, 100, 3000);

INSERT INTO races (Game, Name, Info, PointsAdjust, StrengthAdjust,
IntellectAdjust, EnergyAdjust, AgilityAdjust, VitalityMaxAdjust,
ManaMaxAdjust, HungerMaxAdjust, MinAge, MaxAge) VALUES (-1, 'Dwarf',
'Short and stubborn.', 0, 1, 0, 2, -2, 10, -50, 25, 50, 200);

INSERT INTO races (Game, Name, Info, PointsAdjust, StrengthAdjust,
IntellectAdjust, EnergyAdjust, AgilityAdjust, VitalityMaxAdjust,
ManaMaxAdjust, HungerMaxAdjust, MinAge, MaxAge) VALUES (-1, 'Orc', 'Big
and stupid.', -3, 2, 0, 1, 0, 0, -100, -50, 10, 50);

INSERT INTO classes (Game, Name, Info, StrengthMin, IntellectMin,
EnergyMin, AgilityMin, SpellCasting, Healing, FightingAdjust,
ExperienceLevels) VALUES (-1, 'Fighter', 'Kills things and likes it.',
12, 8, 8, 8, 0, 0, 1, 100);

INSERT INTO classes (Game, Name, Info, StrengthMin, IntellectMin,
EnergyMin, AgilityMin, SpellCasting, Healing, FightingAdjust,
ExperienceLevels) VALUES (-1, 'Magician', 'Zaps things and likes it.',
8, 13, 8, 8, 7, 0, 1, 100);

INSERT INTO classes (Game, Name, Info, StrengthMin, IntellectMin,
EnergyMin, AgilityMin, SpellCasting, Healing, FightingAdjust,
ExperienceLevels) VALUES (-1, 'Priest', 'Heals things and likes it.',
10, 12, 8, 8, 0, 7, 1, 100);

INSERT INTO classes (Game, Name, Info, StrengthMin, IntellectMin,
EnergyMin, AgilityMin, SpellCasting, Healing, FightingAdjust,
ExperienceLevels) VALUES (-1, 'Thief', 'Steals things and likes it.',
10, 10, 11, 11, 1, 1, 0, 100);

Yeah, that's a whole lot of numbers, but follow it carefully and it should be fine. You might want to be more creative with the descriptions: we don't really have space for it here! The most important thing is that they all have -1 for Game, which means they will be generic to all games that don't have classes of their own.

With that done, we can now create the character creation system, taking the user from "welcome to the site" to "character created - click here to play". Keep in mind that we want people to be allowed to play even if they don't login!

The first step here is to edit the front page of the site to list the games that are available for playing, and allow people to jump right in there.

<FORM METHOD="POST" ACTION="playgame.php">
Game to play: <SELECT NAME="GID">

<?php
  $result = mysql_query("SELECT ID, Name FROM games ORDER BY Name ASC;");
  while ($r = mysql_fetch_assoc($result)) {
    extract($r, EXTR_PREFIX_ALL, 'game');
    echo "<OPTION VALUE=\"$game_ID\">$game_Name</OPTION>";
  }
?>

</SELECT>  <INPUT TYPE="SUBMIT" VALUE=" Go! " />
</FORM>

That gives a drop-down on the front page that links people through to playgame.php, passing in the game ID as GID. Here's how playgame.php looks:

<?php
  include 'stdlib.php';
  if (!isset($_REQUEST['GID'])) {
    header("location: index.php");
    exit;
  }
  $_SESSION['IF_CURRENTGAME'] = $_REQUEST['GID'];
  extract($_SESSION, EXTR_PREFIX_ALL, 'sess');

  $result = mysql_query("SELECT Name, Creator, Difficulty, DateCreated, Info,
  MinCharLevel FROM games WHERE ID = $sess_IF_CURRENTGAME;");
  if (!mysql_num_rows($result)) {
    safe_quit();
  }

  extract(mysql_fetch_assoc($result), EXTR_PREFIX_ALL, 'game');
  $IF_PAGETITLE = "Play game: $game_Name";

  include 'header.php';

  echo "<B>Created by:</B> ", print_user($game_Creator), "<BR />";
  echo "<B>Date created:</B> ", short_date($game_DateCreated), "<BR /><BR />";
  echo "<B>Difficulty:</B> ", print_difficulty($game_Difficulty), "<BR />";
  echo "<B>Minimum character level: </B> $game_MinCharLevel<BR /><BR />";
  echo "<I>$game_Info</I><BR /><BR /><BR />";

  echo "<CENTER>";
  if (isset($sess_ID)) {
    echo "<A HREF=\"createchar.php\">Create a character for this game</A>";
  } else {
    echo "<A HREF=\"createchar.php\">Create a character for this game</A>";
  }
  echo "</CENTER>";

  include 'footer.php';
?>

As you should be able to see, that loads up some info on the selected game. The reason for this is because, potentially, we may have hundreds of games in the system, some of which may have similar names - adding this info here will help people distinguish between games a little easier. The safe_quit() function just calls "exit;" right now, but it's important to use this wrapper function because later on we'll be adding a description message: 'your session has been terminated because of a system error' or the like.

Finally, note the isset($sess_ID) check - the same text is printed out irrespective of the value of $sess_ID. Again, this is future planning: further ahead in development we'd like to allow people who are logged in to load up saved characters for this game, but right now it's not possible. Both options link to createchar.php for character creation, which is actually the first of several pages devoted to character creation: it's not an easy thing to do!


Building character

You'll see on your coverdisc eight separate files dedicated to handling the creation of game characters. I'm not going to waste the space here by printing all the code - you can read it yourself! Instead, I'm going to discuss any potentially confusing parts of the code and leave the rest to you. The reason there are so many files to create a character is because it's easier to read the code: we could quite easily have done the same thing with just one script, but it would have required much more complex code.

createchar.php: Note that IF_CURRENTGAME is used to check whether a game has been selected. If not, the user shouldn't be here. The first long SQL statement checks whether there are any races currently defined. If there it, it uses them; otherwise, it pulls out the list of default races. The list of if statements and the $desctext[] array are there to pretty-print the race bonus information.

createchar2.php: This is almost exactly the same as createchar.php

createchar3.php: This chooses the age and sex of the character. The only potential stumbling block here is the way the ages are printed out. Keep in mind that the basic mantra of this system is that the GM should be allowed to configure as much as possible about their game, even if that makes our life difficult now and then. With regards to ages, the issue is that GMs might want to have their races live up to 30 years of age or 30,000. How, then, do we let players choose an age from that? The way I've accomplished it in the code in createchar3.php is to stage the age selection - if the difference between MaxAge and MinAge for a race is under 100 players have 1-year granularity (that is, they can choose 88, 89, 100, etc). However, if the age is over 1000, the granularity is 100 years, allowing players to choose 2900, 3000, 3100, etc.

createchar4.php: This selects a player name. The random name is just there for fun; "Phillip the Flatulent" and "Deirdre the Farmer's Daughter" are probably not the best names!

createchar5.php: This accepts a text box full of information for a life story, and is quite optional.

createchar6.php: This page allows users to set their attributes. It needs a little more work in order to stop people from cheating during character creation, but we'll look at that later.

createchar7.php: This is the player's last chance to review the finished character before it gets created. This is just a shell page right now.

createchar8.php: This inserts the data into the database. The only trick here is where guest characters are created: $OwnerID = -mysql_insert_id() isn't a typo. Instead, it's how we differentiate guest characters from registered characters: anything less than 0 is a guest ID, so we need to abs() it (making it plus) and look it up in the guests table. Finally, note that this page automatically redirects to game.php so that people start playing immediately.

At this point the character is ready to play. There is no way yet to register an account and save the character for use later, so I really wouldn't spend too much time creating characters yet!

If you follow the box, "the big file", you should now have enough to get an initial room showing up. I recommend you try adding the SQL for a room yourself, but if you're totally foxed, try this:

INSERT INTO rooms (Game, Name, Info, SafeToRest, SafeFromEncounters, SafeToAttack
TINYINT, CallTrigger, CallTriggerOnce) VALUES (1, 'The King\'s Head pub', 'It\'s a pub
with lots of people in.', 1, 1, 1, 0, 0);


The big file

Nearly all the action in our text-based adventure will take place in just one file: game.php. Here's where descriptions get read out, items are listed, movement is handled, etc. At this time we're just going to implement a very simple file that handles showing the current room - there's so much more we're going to be adding later, as the game increases in complexity.

In order to explain this file, I'm going to print it here broken up into chunks with comments inbetween. In future issues I'll just be printing the additions to this file as more things are added.

Here's the code:

<?php
  include 'stdlib.php';

  if (!isset($_SESSION['IF_CURRENTGAME'])) {
    header("location: choosegame.php");
    exit;
  }
  if (!isset($_SESSION['IF_CURRENTCHAR'])) {
    header("location: choosegame.php");
    exit;
  }

So, the top lines are there to kick out anyone who doesn't have the necessary information set to play a game: they must have a game selected and they must have a character selected. Simple enough.

  $result = mysql_query("SELECT Name, Creator, Difficulty, RandomEncounters, RandomEncounterStrength FROM
  games WHERE ID = $sess_IF_CURRENTGAME;");
  if (!mysql_num_rows($result)) {
    safe_quit();
  } else {
    extract(mysql_fetch_assoc($result), EXTR_PREFIX_ALL, 'game');
  }

This pulls out the necessary information for the game. Note the call to safe_quit() if things are unrecoverable - that will only be called if the game ID is invalid, which should only be possible if someone has tried to hack the site.

  if (!isset($_SESSION['IF_CURRENTLOCATION'])) {
    $result = mysql_query("SELECT ID FROM rooms WHERE Game = $sess_IF_CURRENTGAME ORDER BY ID ASC LIMIT 1;");
    extract(mysql_fetch_assoc($result), EXTR_PREFIX_ALL, 'room');
    $_SESSION['IF_CURRENTLOCATION'] = $room_ID;
    $sess_IF_CURRENTLOCATION = $room_ID;
  }

We've ascertained that we have a character and a game, but do we know the character's position? If not, we need to put them at the start position. If you recall, this was defined as being the first room the GM created, so we grab all the rooms in the Game, order them by ID, and take the first one. This is then used to set the starting room.

  $result = mysql_query("SELECT Name, Info FROM rooms WHERE ID = $sess_IF_CURRENTLOCATION;");
  if (!mysql_num_rows($result)) {
    safe_quit();
  }
  extract(mysql_fetch_assoc($result), EXTR_PREFIX_ALL, 'room');

The first part there is just some last-minute error checking to make sure it's OK to render the room. If yes, we extract the room data and get ready to print it.

  $result = mysql_query("SELECT Name, Age, Sex, Race, Class, Level, ExperiencePoints, GameState,
  Strength, Intellect, Energy, Agility, Vitality, VitalityMax, Mana, ManaMax, Hunger, HungerMax
  FROM characters WHERE ID = $sess_IF_CURRENTCHAR;");
  if (!mysql_num_rows($result)) {
    safe_quit();
  } else {
    extract(mysql_fetch_assoc($result), EXTR_PREFIX_ALL, 'char');
  }  

We also need the character data - that's what this is.

if ($char_GameState == -1) {
  // set START gamestate
  $result = mysql_query("SELECT ID FROM gamestates WHERE Game = $sess_IF_CURRENTGAME;");
  extract(mysql_fetch_assoc($result), EXTR_PREFIX_ALL, 'gs');
  $char_GameState = $gs_ID;
  mysql_query("UPDATE characters SET GameState = $gs_ID WHERE ID = $sess_IF_CURRENTCHAR;");
}

This part here sort of goes hand in hand without having a room: if there is no game state set, we need to grab the ID of the START game start for this game and set it. It's at this point that we'd also create any autostart items as well as other items in the game: we'll work on that later.

  $IF_PAGETITLE = $room_Name;
  include 'header.php';

  $room_Info = parse_room($room_Info);

  echo "<P>$room_Info</P><BR />";

  include 'footer.php';
?>

Most of that is just header and footer stuff. Note, however, that room data is passed through the parse_room() function before printing it out - the parse_room() function just echoes out its parameter right now, but later we'll need to parse through the text to find any GM code in there. This part is quite complicated, so we'll probably be covering it by itself.