Difference between revisions of "Complete roguelike tutorial using C++ and libtcod - part 10.1: persistence"

From RogueBasin
Jump to navigation Jump to search
Line 525: Line 525:
  }
  }


==Persistent pickables==


Well now that you've seen the method on the destructibles, you can apply it to the pickable. There's a small difference though. The destructible child classes share the same data, thus can use generic save/load function on the base class. On the other hand, the different Pickable child classes are quite different and have to implement their own persistence functions. The only place where we can reuse some code is in the Fireball class. Since it shares the same data as the LightningBolt class, it only needs to implement the save function (to put the right PickableType).
<span style="color:green">class Pickable : public Persistent {</span>
public :
    bool pick(Actor *owner, Actor *wearer);
    void drop(Actor *owner, Actor *wearer);
    virtual bool use(Actor *owner, Actor *wearer);
    <span style="color:green">static Pickable *create (TCODZip &zip);
protected :
    enum PickableType {
        HEALER,LIGHTNING_BOLT,CONFUSER,FIREBALL
    };</span>
};
class Healer : public Pickable {
public :
    float amount; // how many hp
    Healer(float amount);
    bool use(Actor *owner, Actor *wearer);
    <span style="color:green">void load(TCODZip &zip);
    void save(TCODZip &zip);</span>   
};
class LightningBolt : public Pickable {
public :
    float range,damage;
    LightningBolt(float range, float damage);
    bool use(Actor *owner, Actor *wearer);
    <span style="color:green">void load(TCODZip &zip);
    void save(TCODZip &zip);</span>
};
class Confuser : public Pickable {
public :
    int nbTurns;
    float range;
    Confuser(int nbTurns, float range);
    bool use(Actor *owner, Actor *wearer); 
    <span style="color:green">void load(TCODZip &zip);
    void save(TCODZip &zip);</span>
};
class Fireball : public LightningBolt {
public :
    Fireball(float range, float damage);
    bool use(Actor *owner, Actor *wearer); 
    <span style="color:green">void save(TCODZip &zip);</span>
};
The implementation :
void Healer::load(TCODZip &zip) {
    amount=zip.getFloat();
}
void Healer::save(TCODZip &zip) {
    zip.putInt(HEALER);
    zip.putFloat(amount);
}
void LightningBolt::load(TCODZip &zip) {
    range=zip.getFloat();
    damage=zip.getFloat();
}
void LightningBolt::save(TCODZip &zip) {
    zip.putInt(LIGHTNING_BOLT);
    zip.putFloat(range);
    zip.putFloat(damage);
}
void Confuser::load(TCODZip &zip) {
    nbTurns=zip.getInt();
    range=zip.getFloat();
}
void Confuser::save(TCODZip &zip) {
    zip.putInt(CONFUSER);
    zip.putInt(nbTurns);
    zip.putFloat(range);
}
void Fireball::save(TCODZip &zip) {
    zip.putInt(FIREBALL);
    zip.putFloat(range);
    zip.putFloat(damage); 
}
And the factory :
Pickable *Pickable::create(TCODZip &zip) {
    PickableType type=(PickableType)zip.getInt();
    Pickable *pickable=NULL;
    switch(type) {
        case HEALER : pickable=new Healer(0); break;
        case LIGHTNING_BOLT : pickable=new LightningBolt(0,0); break;
        case CONFUSER : pickable=new Confuser(0,0); break;
        case FIREBALL : pickable=new Fireball(0,0); break;
    }
    pickable->load(zip);
    return pickable;
}
[[Category:Developing]]
[[Category:Developing]]

Revision as of 13:37, 16 October 2015

Complete roguelike tutorial using C++ and libtcod
-originally written by Jice
Text in this tutorial was released under the Creative Commons Attribution-ShareAlike 3.0 Unported and the GNU Free Documentation License (unversioned, with no invariant sections, front-cover texts, or back-cover texts) on 2015-09-21.


In this part, we will implement saving and loading the game to be able to close it and resume it later. And this is not going to be pleasing. C++ has a few great feats, but it certainly sucks at type introspection and object serialization. Most modern languages have these features built in. For this reason, the article 10 of Jotaf's python tutorial will be split in two for the C++ version.

In fact, there's a way to serialize a C++ object and store it on the disk using only a couple of lines of code : using boost Serialization library. But we're not going to use it for several reasons :

  • this tutorial is not about using boost but rather how to code the features yourself using basic C++.
  • even if using boost would considerably reduce the size of the code to write, you still have to embed boost in your game. Using hand-made code will replace 8000 lines of boost code with less than 300 lines of specific code, resulting in a lighter executable and a simplified compilation process for your project.
  • using libtcod's zip toolkit means that the save file will be compressed. Of course, this means less space used on the disk, but also harder to hack savegame files. Of course, using boost doesn't keep you from compressing the file, but it just comes at no cost when using libtcod.

Anyway if you prefer to use boost, you can follow this article to reorganize the Map and Engine code, and just skip the whole Persistent stuff and replace Engine::load and Engine::save code with boost invocation.

libtcod features used in this article :

TCODZip

TCODSystem::deleteFile

TCODSystem::fileExists

The plan

Since this part does not include a game menu, we're going to do a minimal savegame support :

  • when the player closes the game window, the game state is saved to a file
  • when the player starts the game, it resumes the last saved game
  • when the player dies, the savegame is deleted (you wouldn't write a roguelike without permadeath, would you?)

While this simplifies the article, it leaves an annoying case : when the player manage to kill all monsters, he will be stuck. The only way to restart a game is to erase the savegame by hand. This will be fixed in the next article.

Refactoring

The engine

The first thing, obviously, is to be able to save and load the whole game. We also need to remove the initializing code from the Engine constructor, because when the Engine is created, we don't know yet if we have to generate a new map or load a previously saved one. We put the functions in the Engine class :

Engine.hpp

   bool pickATile(int *x, int *y, float maxRange = 0.0f);
   void init();
   void load();
   void save();
};

The new constructor doesn't create the map and actors anymore :

Engine::Engine(int screenWidth, int screenHeight) : gameStatus(STARTUP),
   player(NULL),map(NULL),fovRadius(10),
   screenWidth(screenWidth),screenHeight(screenHeight) {
   TCODConsole::initRoot(screenWidth,screenHeight,"libtcod C++ tutorial",false);
   gui = new Gui();
}

All the initialization code has been moved to the init function :

void Engine::init() { 
   player = new Actor(40,25,'@',"player",TCODColor::white);
   player->destructible=new PlayerDestructible(30,2,"your cadaver");
   player->attacker=new Attacker(5);
   player->ai = new PlayerAi();
   player->container = new Container(26);
   actors.push(player);
   map = new Map(80,43);
   map->init(true);
   gui->message(TCODColor::red, 
       "Welcome stranger!\nPrepare to perish in the Tombs of the Ancient Kings.");
}

Now the load function will either load a saved game or call Engine::init to create a new one. We simply call it before the main loop. We also save the game once the player closes the game window.

main.cpp :

int main() {
   engine.load();
   while ( !TCODConsole::isWindowClosed() ) {
       engine.update();
       engine.render();
   TCODConsole::flush();    
   }
   engine.save();
   return 0;
}

The map

We could save the map by putting every tile property in the file. Or we can only save the seed of the random number generator and re-generate the map. For this, we need to store the seed and the random number generator in the map class. Previously, we were using libtcod's default random number generator with an unknown seed. We also have to move the map initialization code out of the constructor so that we're able to call it when the map is loaded from the file :

Map.hpp

  void init(bool withActors);
protected :
  Tile *tiles;
  TCODMap *map;
  long seed;
  TCODRandom *rng;
  friend class BspListener;

  void dig(int x1, int y1, int x2, int y2);
  void createRoom(bool first, int x1, int y1, int x2, int y2, bool withActors);

The withActors parameter tells the map whether it should create monsters and items or if they will be loaded from a saved game. The constructor new only gets some random seed :

Map::Map(int width, int height) 
   : width(width),height(height) {
   seed=TCODRandom::getInstance()->getInt(0,0x7FFFFFFF);
}

In case you wonder, 0x7FFFFFFF is the highest possible 32 bit signed integer value. The init function uses the code that was previously in the constructor, but the splitRecursive function now uses the map's RNG :

void Map::init(bool withActors) {
   rng = new TCODRandom(seed, TCOD_RNG_CMWC);
   tiles=new Tile[width*height];
   map=new TCODMap(width,height);
   TCODBsp bsp(0,0,width,height);
   bsp.splitRecursive(rng,8,ROOM_MAX_SIZE,ROOM_MAX_SIZE,1.5f,1.5f);
   BspListener listener(*this);
   bsp.traverseInvertedLevelOrder(&listener,(void *)withActors);
}

The withActors boolean is passed the the BSP listener in the userData parameter. It's a void * parameter that can contain pretty much anything. You can use it to store any numeric value (an int, a float, a boolean, a char...) or the adress of some struct/class. Here, we're simply casting the boolean into void *.

The room creation code now uses the map's RNG instead of libtcod's default one. It also retrieve the withActors value to pass it to the createRoom function. In Map.cpp, BspListener::visitNode :

   bool withActors=(bool)userData;
// dig a room
w=map.rng->getInt(ROOM_MIN_SIZE, node->w-2);
h=map.rng->getInt(ROOM_MIN_SIZE, node->h-2);
x=map.rng->getInt(node->x+1, node->x+node->w-w-1);
y=map.rng->getInt(node->y+1, node->y+node->h-h-1);
map.createRoom(roomNum == 0, x, y, x+w-1, y+h-1, withActors);

The createRoom function digs the room, then handle actors only if withActors is true :

void Map::createRoom(bool first, int x1, int y1, int x2, int y2, bool withActors) {
   dig (x1,y1,x2,y2);
   if (!withActors) {
       return;
   }
   if ( first ) {

If you want to be able to restart a game using the exact same map as before by entering the same seed, you also need to use the map's RNG in the createRoom, addMonster and addItem functions. But since there's no way to enter a seed right now, we won't do it.

A persistent engine

Now everything is ready and all we have to do is to implement the Engine::save and Engine::load functions. If you want to use boost, it's time you leave this tutorial and go on your own. For the others, brace yourselves ! It's time to reinvent the wheel ! Let's define some abstract interface for everything that must be saved :

Persistent.hpp :

class Persistent {
public :
   virtual void load(TCODZip &zip) = 0;
   virtual void save(TCODZip &zip) = 0;
};

This file has to be included early in main.hpp as pretty much everything must inherit from Persistent.

main.hpp :

#include "libtcod.hpp"
class Actor;
#include "Persistent.hpp"
#include "Destructible.hpp"

We're going to use the top-down way, starting with the higher level class down to the smallest ones. In the attached source code, all the save/load method have been implemented in the Persistent.cpp file. While that might not be very orthodox, it helps keeping the bloat out of all the cpp files and having all the persistent stuff in a single file. Let's start with the Engine class. We obviously have to save the map and the actors. Another thing is the message log.

First, handle the permadeath :

void Engine::save() {
   if ( player->destructible->isDead() ) {
       TCODSystem::deleteFile("game.sav");

else save the map :

   } else {
       TCODZip zip;
       // save the map first
               zip.putInt(map->width);
               zip.putInt(map->height);
       map->save(zip);

The map could save the width/height fields in its own save function, but since it won't be able to load them (because we need them to create the Map object), we keep the symmetry between the load and save functions and handle the width/height field in the Engine.

then the actors. Note that since we want to be able to know who is the player, we save him first. That's why we decrement the number of remaining actors (actors.size()-1).

// then the player
player->save(zip);
// then all the other actors
zip.putInt(actors.size()-1);
for (Actor **it=actors.begin(); it!=actors.end(); it++) {
   if ( *it != player ) {
       (*it)->save(zip);
   }
}

Finally, the message log. Then dump the compressed data to a file :

       // finally the message log
       gui->save(zip);
       zip.saveToFile("game.sav");
   }
}

I always start with the save function because it seems more intuitive to me. Then, writing the load function is just a mirroring process :

void Engine::load() {
   if ( TCODSystem::fileExists("game.sav")) {
       TCODZip zip;
       zip.loadFromFile("game.sav");
       // load the map
               int width=zip.getInt();
               int height=zip.getInt();
               map = new Map(width,height);
       map->load(zip);

Then we load the player.

// then the player
player=new Actor(0,0,0,NULL,TCODColor::white);
player->load(zip);
actors.push(player);

The rest of the code is almost copied and pasted from the save function :

   // then all other actors
   int nbActors=zip.getInt();
   while ( nbActors > 0 ) {
       Actor *actor = new Actor(0,0,0,NULL,TCODColor::white);
       actor->load(zip);
       actors.push(actor);
       nbActors--;
   }
   // finally the message log
   gui->load(zip);
}

Note that we have to call the Actor constructor with dummy parameters. We could define some default Actor::Actor() constructor for that. If there is no game.sav file, we initialize some new random map :

   } else {
       engine.init();
   }
}

A persistent message log

Ok that was pretty easy so far. The message log persistence is also quite simple to implement :

Gui.hpp :

class Gui : public Persistent {
public :
   Gui();
   ~Gui();
   void render();
   void message(const TCODColor &col, const char *text, ...);
   void load(TCODZip &zip);
   void save(TCODZip &zip);
void Gui::save(TCODZip &zip) {
   zip.putInt(log.size());
   for (Message **it=log.begin(); it != log.end(); it++) {
       zip.putString((*it)->text);
       zip.putColor(&(*it)->col);
   }
}

Note that TCODZip can store C strings (char *) and TCODColor as easily as an int. It can also store a whole TCODConsole or a TCODImage. The putColor function's parameter is a pointer to a TCODColor.

void Gui::load(TCODZip &zip) {
   int nbMessages=zip.getInt();
   while (nbMessages > 0) {
               const char *text=zip.getString();
               TCODColor col=zip.getColor();
               message(col,text);
               nbMessages--;
   }
}

The data returned by TCODZip::getString() won't be available once the TCODZip object is destroyed (as soon as we exit the Engine::load function). So it's very important to always duplicate the string. In our case, we don't need to do it because the message function already duplicates the text parameter using strdup.

A persistent map

Let Map implement the Persistent interface :

Map.hpp

class Map : public Persistent {
public :
   int width,height;

   ... 

   void load(TCODZip &zip);
   void save(TCODZip &zip);

We save the seed that will allow to reconstruct the dungeon without having to save the whole tile grid, and the memory map (list of explored tiles).

void Map::save(TCODZip &zip) {
   zip.putInt(seed);
   for (int i=0; i < width*height; i++) {
       zip.putInt(tiles[i].explored);
   }
}

We store the explored booleans as int since TCODZip only support a few basic types. The conversion is implicit (false=0, true=1). Now let's implement the loading part :

void Map::load(TCODZip &zip) {
   seed=zip.getInt();
       init(false);

Initializing the map with withActors = false will dig the rooms, but create no actor nor try to move the player in the first room. Then we restore the memory map :

   for (int i=0; i < width*height; i++) {
       tiles[i].explored=zip.getInt();
   }
}

Persistent actors

Ok that was the easy part. It's not too late to go back to boost, or switch to another language, or give up game programming and instead focus on something else like narwhals... or bacon...

Well at first, it doesn't seem much different. Actor implements Persistent :

class Actor : public Persistent {
public :
   int x,y; // position on map

   ...

   void load(TCODZip &zip);
   void save(TCODZip &zip); 
};

We save all the basic properties of the actor :

void Actor::save(TCODZip &zip) {
   zip.putInt(x);
   zip.putInt(y);
   zip.putInt(ch);
   zip.putColor(&col);
   zip.putString(name);
   zip.putInt(blocks);

Then a boolean for each feature available (we could use a bit field but why bother, the file is compressed) :

zip.putInt(attacker != NULL);
zip.putInt(destructible != NULL);
zip.putInt(ai != NULL);
zip.putInt(pickable != NULL);
zip.putInt(container != NULL);

Then the features themselves :

   if ( attacker ) attacker->save(zip);
   if ( destructible ) destructible->save(zip);
   if ( ai ) ai->save(zip);
   if ( pickable ) pickable->save(zip);
   if ( container ) container->save(zip);
}

The loading part is symmetric. Properties :

void Actor::load(TCODZip &zip) {
   x=zip.getInt();
   y=zip.getInt();
   ch=zip.getInt();
   col=zip.getColor();
   name=strdup(zip.getString());
   blocks=zip.getInt();

Features booleans :

bool hasAttacker=zip.getInt();
bool hasDestructible=zip.getInt();
bool hasAi=zip.getInt();
bool hasPickable=zip.getInt();
bool hasContainer=zip.getInt();

Then comes the feature loading. Attacker is easy :

if ( hasAttacker ) {
   attacker = new Attacker(0.0f);
   attacker->load(zip);
}

Once again, we use a dummy value in the constructor but you could add a constructor with no parameter.

Now Destructible is quite different because our actors don't contain Destructible fields, but rather MonsterDestructible or PlayerDestructible. Since we can't call the load function on an object because we don't know what to create, we'll use some simplified factory pattern, using a static function on the Destructible class to create the Destructible object for us ;

if ( hasDestructible ) {
   destructible = Destructible::create(zip);
}

The same happens for the Ai and Pickable classes since they both have descendant classes :

if ( hasAi ) {
   ai = Ai::create(zip);
}
if ( hasPickable ) {
   pickable = Pickable::create(zip);
}

Finally, back to the classic way with the Container feature :

   if ( hasContainer ) {
       container = new Container(0);
       container->load(zip);
   }
}

Persistent attackers

This one is very easy :

class Attacker : public Persistent {
public :
   float power; // hit points given

   Attacker(float power);
   void attack(Actor *owner, Actor *target);
   void load(TCODZip &zip);
   void save(TCODZip &zip);
};
void Attacker::load(TCODZip &zip) {
   power=zip.getFloat();
}

void Attacker::save(TCODZip &zip) {
   zip.putFloat(power);
}

Persistent containers

A bit harder but we already know how to load and save actors :

class Container : public Persistent {
public :
   int size; // maximum number of actors. 0=unlimited

       ...

   void remove(Actor *actor);
   void load(TCODZip &zip);
   void save(TCODZip &zip);
};
void Container::load(TCODZip &zip) {
   size=zip.getInt();
   int nbActors=zip.getInt();
   while ( nbActors > 0 ) {
       Actor *actor=new Actor(0,0,0,NULL,TCODColor::white);
       actor->load(zip);
       inventory.push(actor);
       nbActors--;
   }
}

void Container::save(TCODZip &zip) {
   zip.putInt(size);
   zip.putInt(inventory.size());
   for (Actor **it=inventory.begin(); it != inventory.end(); it++) {
       (*it)->save(zip);
   }
}

Persistent destructibles

To be able to restore the right type of destructible, we'll store some type information in the savegame file. The factory function will first read this type, then instantiate the Destructible and finally call the load function.

class Destructible : public Persistent {
public :
   float maxHp; // maximum health points

       ...

   void load(TCODZip &zip);
   void save(TCODZip &zip);    
   static Destructible *create(TCODZip &zip);
protected :
   enum DestructibleType {
       MONSTER,PLAYER
   };
};

The load and save functions only deal with the generic part :

void Destructible::load(TCODZip &zip) {
   maxHp=zip.getFloat();
   hp=zip.getFloat();
   defense=zip.getFloat();
   corpseName=strdup(zip.getString());
}

void Destructible::save(TCODZip &zip) {
   zip.putFloat(maxHp);
   zip.putFloat(hp);
   zip.putFloat(defense);
   zip.putString(corpseName);
}

Note how we always duplicate the data returned by the getString function. This will create a small memory leak since the corpseName field is not destroyed when the Destructible is deleted. In fact we cannot delete the corpseName field because it does not always contain a dynamically allocated string (using new, strdup of malloc/calloc). So far, corpseName always contained a static string (like "dead orc") that cannot be deleted because it points to a static part of memory inside the program's code (in fact if you open the .exe file with an hexadecimal editor, you will find those strings). One way to get rid of the memory leak is to always use dynamically allocated strings :

Destructible::Destructible(float maxHp, float defense, const char *corpseName) :
   maxHp(maxHp),hp(maxHp),defense(defense){
   this->corpseName = strdup(corpseName);
}

Destructible::~Destructible() {
   free(corpseName);
}

Now we can implement the save function on the MonsterDestructible and PlayerDestructible class. They put the type value in the file before calling the generic save function :

void PlayerDestructible::save(TCODZip &zip) {
   zip.putInt(PLAYER);
   Destructible::save(zip);
}

void MonsterDestructible::save(TCODZip &zip) {
   zip.putInt(MONSTER);
   Destructible::save(zip);
}

Now, the factory function can use this information :

Destructible *Destructible::create(TCODZip &zip) {
   DestructibleType type=(DestructibleType)zip.getInt();
   Destructible *destructible=NULL;
   switch(type) {
       case MONSTER : destructible=new MonsterDestructible(0,0,NULL); break;
       case PLAYER : destructible=new PlayerDestructible(0,0,NULL); break;
   }
   destructible->load(zip);
   return destructible;
}

Persistent pickables

Well now that you've seen the method on the destructibles, you can apply it to the pickable. There's a small difference though. The destructible child classes share the same data, thus can use generic save/load function on the base class. On the other hand, the different Pickable child classes are quite different and have to implement their own persistence functions. The only place where we can reuse some code is in the Fireball class. Since it shares the same data as the LightningBolt class, it only needs to implement the save function (to put the right PickableType).

class Pickable : public Persistent {
public :
   bool pick(Actor *owner, Actor *wearer);
   void drop(Actor *owner, Actor *wearer);
   virtual bool use(Actor *owner, Actor *wearer);
   static Pickable *create (TCODZip &zip);
protected :
   enum PickableType {
       HEALER,LIGHTNING_BOLT,CONFUSER,FIREBALL
   };
};

class Healer : public Pickable {
public :
   float amount; // how many hp

   Healer(float amount);
   bool use(Actor *owner, Actor *wearer);
   void load(TCODZip &zip);
   void save(TCODZip &zip);    
};

class LightningBolt : public Pickable {
public :
   float range,damage;
   LightningBolt(float range, float damage);
   bool use(Actor *owner, Actor *wearer);
   void load(TCODZip &zip);
   void save(TCODZip &zip);
};

class Confuser : public Pickable {
public :
   int nbTurns;
   float range;
   Confuser(int nbTurns, float range);
   bool use(Actor *owner, Actor *wearer);  
   void load(TCODZip &zip);
   void save(TCODZip &zip);
};

class Fireball : public LightningBolt {
public :
   Fireball(float range, float damage);
   bool use(Actor *owner, Actor *wearer);  
   void save(TCODZip &zip);
};

The implementation :

void Healer::load(TCODZip &zip) {
   amount=zip.getFloat();
}

void Healer::save(TCODZip &zip) {
   zip.putInt(HEALER);
   zip.putFloat(amount);
}

void LightningBolt::load(TCODZip &zip) {
   range=zip.getFloat();
   damage=zip.getFloat();
}

void LightningBolt::save(TCODZip &zip) {
   zip.putInt(LIGHTNING_BOLT);
   zip.putFloat(range);
   zip.putFloat(damage);
}

void Confuser::load(TCODZip &zip) {
   nbTurns=zip.getInt();
   range=zip.getFloat();
}

void Confuser::save(TCODZip &zip) {
   zip.putInt(CONFUSER);
   zip.putInt(nbTurns);
   zip.putFloat(range);
}

void Fireball::save(TCODZip &zip) {
   zip.putInt(FIREBALL);
   zip.putFloat(range);
   zip.putFloat(damage);   
}

And the factory :

Pickable *Pickable::create(TCODZip &zip) {
   PickableType type=(PickableType)zip.getInt();
   Pickable *pickable=NULL;
   switch(type) {
       case HEALER : pickable=new Healer(0); break;
       case LIGHTNING_BOLT : pickable=new LightningBolt(0,0); break;
       case CONFUSER : pickable=new Confuser(0,0); break;
       case FIREBALL : pickable=new Fireball(0,0); break;
   }
   pickable->load(zip);
   return pickable;
}