| [ LiB ] |
Entities are finally finished, so you can sigh in relief or have a party. Now, I'll show you the marvelous game loop .
The game loop is a concept in SimpleMUD that handles everything that depends on timers. For example, the databases need to be saved at particular time intervals, players need to heal, and enemies need to look for players to attack. The game loop
NOTE
Up until this point, the SimpleMUD was purely an event-based system; everything that happened in the game was triggered by something a connected player sent to the game. The game loop adds some independent initiative to the game, so that it will actively perform actions when it needs to, instead of just responding to player input.
The game loop is stored in a class appropriately named GameLoop , which you can find in the files /SimpleMUD/GameLoop.h and .cpp. Here's a listing of the class:
class GameLoop {
public:
GameLoop() { LoadDatabases(); }
~GameLoop() { SaveDatabases(); }
void LoadDatabases(); // load all databases
void SaveDatabases(); // save all databases
void Load(); // load gameloop data
void Save(); // save gameloop data
void Loop(); // perform one loop iteration
void PerformRound(); // perform combat round
void PerformRegen(); // perform enemy regen round
void PerformHeal(); // perform healing round
protected:
BasicLib::sint64 m_savedatabases;
BasicLib::sint64 m_nextround;
BasicLib::sint64 m_nextregen;
BasicLib::sint64 m_nextheal;
};
First, take a look at the constructor and destructor. They call the
LoadDatabases
and
SaveDatabases
functions respectively. So whenever you create a
GameLoop
object, all of the databases are automatically loaded from disk, and whenever a
GameLoop
object is
I'll get more into system timing in a little bit.
NOTE
System time is kept in
When the database loading/saving functions are called, they are designed to automatically load or save every database that needs to be loaded or saved to disk. For example, the
GameLoop::LoadDatabase
function is as
void GameLoop::LoadDatabases() {
Load();
ItemDatabase::Load();
PlayerDatabase::Load();
RoomDatabase::LoadTemplates();
RoomDatabase::LoadData();
StoreDatabase::Load();
EnemyTemplateDatabase::Load();
EnemyDatabase::Load();
}
The
Load
function deals with loading the four system-time
Saving is similar, but since not all databases need to be saved back to disk, the function is simpler:
void GameLoop::SaveDatabases() {
Save();
PlayerDatabase::Save();
RoomDatabase::SaveData();
EnemyDatabase::Save();
}
In addition to the time variables saved with the GameLoop::Save function, only players, rooms, and enemies need to be saved to disk.
The time variables are saved within a text file entitled game.data. When you start your MUD for the first time, the game time starts at 0 and
The GameLoop::Save function saves the time variables to disk:
void GameLoop::Save() {
std::ofstream file( "game.data" );
file << "[GAMETIME] ";
insert( file, Game::GetTimer().GetMS() ); file << "\n";
file << "[SAVEDATABASES] ";
insert( file, m_savedatabases ); file << "\n";
file << "[NEXTROUND] ";
insert( file, m_nextround ); file << "\n";
file << "[NEXTREGEN] ";
insert( file, m_nextregen ); file << "\n";
file << "[NEXTHEAL] ";
insert( file, m_nextheal ); file << "\n";
}
I've used the
BasicLib::insert
function to insert the times into the text file (due to the fact that VC6 does not stream 64-bit integers properly). As you can see, the function
[GAMETIME] 3917661 [SAVEDATABASES] 4500000 [NEXTROUND] 3918000 [NEXTREGEN] 3970000 [NEXTHEAL] 3960000
Since the times are millisecond-based, you can tell that the server represented by this file has been up for about an
Loading the variables is slightly more complicated. In a "clean" installation of SimpleMUD, the game.data text file won't exist, so all five timers must be reset to the default values.
First, let me show you the default value definitions:
sint64 DBSAVETIME = minutes( 15 ); sint64 ROUNDTIME = seconds( 1 ); sint64 REGENTIME = minutes( 2 ); sint64 HEALTIME = minutes( 1 );
These values determine how much time should pass between certain actions. For example, the databases should be saved every 15 minutes (the
seconds
and
minutes
helpers are in the BasicLib), combat rounds occur every second, monsters regen every two minutes, and players heal once a minute. I refer to these values as
delta
values. (Delta is a mathematical
Now the loading function:
void GameLoop::Load() {
std::ifstream file( "game.data" ); // open file
file >> std::ws; // eat whitespace
if( file.good() ) { // detect if file is good
std::string temp;
sint64 time;
file >> temp; extract( file, time ); // read system time
Game::GetTimer().Reset( time ); // reset timer to that time
// read other variables:
file >> temp; extract( file, m_savedatabases );
file >> temp; extract( file, m_nextround );
file >> temp; extract( file, m_nextregen );
file >> temp; extract( file, m_nextheal );
}
The previous code segment is executed when the game.data file exists. It loads in the current system time, and resets the Game 's timer object to the current system time, and then proceeds to load the other variables.
If the file is corrupted, however, this code is executed:
else {
Game::GetTimer().Reset();
m_savedatabases = DBSAVETIME;
m_nextround = ROUNDTIME;
m_nextregen = REGENTIME;
m_nextheal = HEALTIME;
}
Game::Running() = true;
}
The game timer is reset to 0, and the four time values are set to their deltas, meaning that the first database saving will occur at 15 minutes, and so on.
Finally, the game is told to start running.
In the SimpleMUD, the GameLoop::Loop is called once every cycle, and this function takes care of the various time-based activities:
void GameLoop::Loop() {
if( Game::GetTimer().GetMS() >= m_nextround ) {
PerformRound();
m_nextround += ROUNDTIME;
}
if( Game::GetTimer().GetMS() >= m_nextregen ) {
PerformRegen();
m_nextregen += REGENTIME;
}
if( Game::GetTimer().GetMS() >= m_nextheal ) {
PerformHeal();
m_nextheal += HEALTIME;
}
if( Game::GetTimer().GetMS() >= m_savedatabases ) {
SaveDatabases();
m_savedatabases += DBSAVETIME;
}
}
The function checks the four timer variables to see if their time has come. For example, if
m_nextround
is 4534000, and the timer is at 4534013 (13 milliseconds later), the
PerformRound
function is called, and
ROUNDTIME
(1 second, or 1000 milliseconds) is added to
m_nextround
, making it 45345000. Since the timer is not
NOTE
This is an inefficient method of timing, but it serves its
purpose in SimpleMUD. Imagine if you had more than four
different timed events. The function would become large
and
Whenever the game decides to perform a combat round, the GameLoop::PerformRound function is called. This function simply goes through every enemy in the game, and if the enemy sees a player in the same room, it is told to attack:
void GameLoop::PerformRound() {
EnemyDatabase::iterator itr = EnemyDatabase::begin();
sint64 now = Game::GetTimer().GetMS();
while( itr != EnemyDatabase::end() ) {
if( now >= itr->NextAttackTime() && // make sure enemy can attack
itr->CurrentRoom()->Players().size() > 0 ) // check players
Game::EnemyAttack( itr->ID() ); // tell enemy to attack
++itr;
}
}
For every enemy, two things are checked: Can the enemy attack, and are there any players in the room? Enemies keep track of the next time they are allowed to attack, which is based on which weapon they are using. You'll see how this works when I show you the Attack function.
If there are no players in the room, there's obviously no one for the enemy to attack, so the function doesn't do anything.
Every two minutes, the game
The process for this entails looping through every room, and determining if a new enemy should be spawned:
void GameLoop::PerformRegen() {
RoomDatabase::iterator itr = RoomDatabase::begin();
while( itr != RoomDatabase::end() ) {
if( itr->SpawnWhich() != 0 && // make sure room can spawn enemies
itr->Enemies().size() < itr->MaxEnemies() ) // don't overflow
{
// tell the database to create a new enemy in the room
EnemyDatabase::Create( itr->SpawnWhich(), itr->ID() );
Game::SendRoom( red + bold + itr->SpawnWhich()->Name() +
" enters the room!", itr->ID() );
}
++itr;
}
}
It's not a complex process; it uses the EnemyDatabase::Create function to create enemies in each room that needs them. The loop checks two conditions per room: First, it determines if enemies should be created (because rooms with a SpawnWhich ID of 0 shouldn't spawn any enemies), and second, it decides if more enemies can fit into the room.
If these conditions are met, a new enemy is created, and a message is sent to the room
NOTE
This is also an inefficient process, but it works for SimpleMUD. In the next part of the book, I explore more efficient methods of filling your realm with enemies, as well as other things, such as the impact on the economy. If you think about it, an endless supply of enemies means an endless supply of loot, and therefore an endless cycle of inflation.
It is typical in MUD-like
Once every minute, the game goes through all the players, and if they are logged in, the players' hitpoints are regenerated:
void GameLoop::PerformHeal() {
PlayerDatabase::iterator itr = PlayerDatabase::begin();
while( itr != PlayerDatabase::end() ) {
if( itr->Active() ) {
itr->AddHitpoints( itr->GetAttr( HPREGEN ) );
itr->PrintStatbar( true );
}
++itr;
}
}
As you can see, the player's HPREGEN attribute is added to his hitpoints, and his statbar is updated.
NOTE
Note that
true
is passed into the
PrintStatbar
function. When the
parameter is
true
, it
Finally, we reach the best part of the game controlling what happens when enemies attack and when they kill players. Even though these functions are called by the GameLoop , they reside within the Game class, so I can keep the logic in one central place.
Here are the declarations of the two functions that control this:
void EnemyAttack( enemy p_enemy ); void PlayerKilled( player p_player );
The
Attack
function tells the game that
p_enemy
should find a player and attack him. The
The Attack function follows the basic combat rules I defined in Chapter 7. Before I get into that, however, the enemy first needs to figure out who he wants to attack. This is handled with the assistance of the std::advance function, which takes an iterator, and moves it forward however many places you specify:
void Game::EnemyAttack( enemy p_enemy ) {
Enemy& e = *p_enemy; // get enemy
room r = e.CurrentRoom(); // get room
std::list<player>::iterator itr = r->Players().begin();
std::advance( itr, BasicLib::RandomInt( 0, r->Players().size() - 1 ) );
Basically, the previous code skips to a random player in the room, which means that the enemy attacks a random person every round. Although these random attacks are not realistic, I did this for "security" reasons: It
The next part of the code calculates how much damage is done by the enemy's current weapon:
Player& p = **itr; // get player
sint64 now = Game::GetTimer().GetMS();
int damage;
if( e.Weapon() == 0 ) { // fists, 1-3 damage, 1 second swingtime
damage = BasicLib::RandomInt( 1, 3 );
e.NextAttackTime() = now + seconds( 1 );
}
else { // weapon, damage, and swing time based on weapon attributes
damage = BasicLib::RandomInt( e.Weapon()->Min(), e.Weapon()->Max() );
e.NextAttackTime() = now + seconds( e.Weapon()->Speed() );
}
If the enemy doesn't have a weapon, it is assumed the enemy will damage things in the 13 range, with a swing time of one second. (This means that the enemy can attack again in one second.) If the enemy is using a weapon, the damage range and swing time are taken directly from that enemy's weapon object.
Next, the game figures out if the enemy hit the player.
if( BasicLib::RandomInt( 0, 99 ) >= e.Accuracy() -
p.GetAttr( DODGING ) )
{
Game::SendRoom( white + e.Name() + " swings at " + p.Name() +
" but misses!", e.CurrentRoom() );
return;
}
damage += e.StrikeDamage();
damage -= p.GetAttr( DAMAGEABSORB );
if( damage < 1 )
damage = 1;
The code generates a random number from 0 to 99, and if the accuracy of the enemy minus the dodging of the player is more than or equal to the random number, the enemy missed.
For example, an enemy has an accuracy of 80, and the player has a dodging level of 20. Since 80 20 = 60, if the random number is between 60 and 99, the enemy misses. That means that the enemy hits the player about 60% of the time. If the enemy misses, the function returns.
NOTE
Calculating whether the enemy hit a player could have been accomplished earlier, but it turns out that waiting until this point saves code. No matter what, when an enemy swings, its next attack time should be increased. So while you're calculating the next attack time, why not calculate the damage as well?
If the enemy
does
hit the player, the enemy's strike damage is added to the overall damage value, and the player's damage
You don't want enemies doing negative damage (that is, adding hitpoints), or 0 damage to players (which would mean that the enemy missed, but we've already determined that he hit the player).
Here's the final
p.AddHitpoints( -damage );
Game::SendRoom( red + e.Name() + " hits " + p.Name() + " for " +
tostring( damage ) + " damage!", e.CurrentRoom() );
if( p.HitPoints() <= 0 )
PlayerKilled( p.ID() );
}
The damage is removed from the player, and the room is told that the player got hit. Finally, if the player's hitpoints are 0 or lower, the game is notified that the player was killed.
As in real life, everyone dies someday. In a dangerous world such as the SimpleMUDs, death comes often if you're not careful.
Whenever a player is killed, the
Game::PlayerKilled
is called, and the unfortunate player is
void Game::PlayerKilled( player p_player ) {
Player& p = *p_player; // get the player
Game::SendRoom( red + bold + p.Name() + " has died!", p.CurrentRoom() );
money m = p.Money() / 10; // calclate how much money to drop
if( m > 0 ) {
p.CurrentRoom()->Money() += m;
p.Money() -= m;
Game::SendRoom( cyan + "$" + tostring( m ) +
" drops to the ground.", p.CurrentRoom() );
}
The function in this code segment tells the room that a player has died, calculates how much money to drop, and then drops it.
The next part of code drops a random item:
if( p.Items() > 0 ) { // make sure the player has an item
// loop through random indexes until you hit a valid item:
int index = -1;
while( p.GetItem( index = RandomInt( 0, PLAYERITEMS - 1 ) ) == 0 );
item i = p.GetItem( index ); // get the item to drop
p.CurrentRoom()->AddItem( i ); // add it to the room
p.DropItem( index ); // remove it from the player
Game::SendRoom( cyan + i->Name() + " drops to the ground.",
p.CurrentRoom() );
}
The function essentially performs a "random bounce" to find an inventory item to drop. This is somewhat
After an item is found, the item is added to the room and removed from the player's inventory, and the room is told that the item was dropped.
The final part of the code subtracts 10% experience, and moves the player to Town Square:
int exp = p.Experience() / 10;
p.Experience() -= exp; // subtract 10% exp
p.CurrentRoom()->RemovePlayer( p_player );
p.CurrentRoom() = 1; // move player to room 1
p.CurrentRoom()->AddPlayer( p_player );
// set player HP to 70%
p.SetHitpoints( (int)(p.GetAttr( MAXHITPOINTS ) * 0.7) );
// send messages:
p.SendString( white + bold +
"You have died, but have been ressurected in " +
p.CurrentRoom()->Name() );
p.SendString( red + bold + "You have lost " + tostring( exp ) +
" experience!" );
Game::SendRoom( white + bold + p.Name() +
" appears out of nowhere!!" , p.CurrentRoom() );
}
The player's hitpoints are reset to 70% of maximum. You obviously don't want the player to be ressurected with 0 (or less!) hitpoints, but you also don't want the player to start off with full hitpoints either, so 70% is a good
The last thing the function does is print out messages to the player and to the people in the room in which the player respawns, telling them about his
| [ LiB ] |