Complete roguelike tutorial using C++ and libtcod - part 8: items and inventory

From RogueBasin
Revision as of 08:28, 1 March 2016 by Ontoclasm (Talk | contribs)

(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)
Jump to: navigation, search
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 article, we will start to add items to the dungeon, in the form of health potions. This will make it possible to kill every monster in the dungeon for the first time, even if the engine won't yet detect the end of game.

libtcod functions used in this article :

TCODConsole::printFrame

TCODSystem::waitForEvent

View source here

Contents

Actors that can be healed

For the health potion to work, we first need to be able to heal a Destructible. Let's add this. Destructible.hpp :

float heal(float amount);

Destructible.cpp :

float Destructible::heal(float amount) {
   hp += amount;
   if ( hp > maxHp ) {
       amount -= hp-maxHp;
       hp=maxHp;
   }
   return amount;
}

The function returns the amount of health point actually restored.

Pickable actors and containers

Now, we can start to make items that we can pick. Roguelikes often make a strong separation between creatures and items. Creatures have inventory and items can be picked. This can lead to unnecessary complications. For example, a chest is an item, has an inventory and cannot be picked.

Here we will break this separation and simply create actors that can be picked (and used) and actors that can contain other actors (a chest or a creature with an inventory). Let's add those two new features to our almighty Actor class :

Ai *ai; // something self-updating
Pickable *pickable; // something that can be picked and used
Container *container; // something that can contain actors

Include the headers (yet to be written) in main.hpp :

#include "Ai.hpp"
#include "Pickable.hpp"
#include "Container.hpp"
#include "Actor.hpp"

And initialize the features in the Actor constructor :

Actor::Actor(int x, int y, int ch, const char *name,
   const TCODColor &col) :
   x(x),y(y),ch(ch),col(col), name(name),
   blocks(true),attacker(NULL),destructible(NULL),ai(NULL),
   pickable(NULL),container(NULL) {
}   

Ok. So far we didn't bother with what happens when an actor is destroyed (because it never happens in the game since even a dead creature is an Actor). But when we're going to drink a health potion, we expect it to disappear from our inventory, so we're actually doing to delete the Actor object. Let's add a destructor that cleans the features :

Actor.hpp:

Actor(int x, int y, int ch, const char *name, const TCODColor &col);
~Actor();
void update();

Actor.cpp :

Actor::~Actor() {
   if ( attacker ) delete attacker;
   if ( destructible ) delete destructible;
   if ( ai ) delete ai;
   if ( pickable ) delete pickable;
   if ( container ) delete container;
}

Since Destructible, Ai, and Pickable will be derived classes, we need to define a virtual destructor for each in their respective hpp files in order to safely delete them:

virtual ~Destructible() {};
virtual ~Ai() {};
virtual ~Pickable() {};

Container implementation

Our container will obviously contain a list of actors and a capacity : Container.hpp :

class Container {
public :
   int size; // maximum number of actors. 0=unlimited
   TCODList<Actor *> inventory;       

   Container(int size);
   ~Container();
   bool add(Actor *actor);
   void remove(Actor *actor);
};

The constructor initializes the size field :

Container::Container(int size) : size(size) {
}

The destructor deletes all actors in the container :

Container::~Container() {
   inventory.clearAndDelete();
}

The add method checks that the container is not full.

bool Container::add(Actor *actor) {
   if ( size > 0 && inventory.size() >= size ) {
       // inventory full
       return false;
   }

Then add the actor :

   inventory.push(actor);
   return true;
}

The remove method... well you already know :

void Container::remove(Actor *actor) {
   inventory.remove(actor);
}

Ok that was really easy. Let's add a 26 slots inventory to the player. Why 26 ? To be able to effect a letter shortcut to each slot. In the Engine constructor :

player->ai = new PlayerAi();
player->container = new Container(26);
actors.push(player);

Pickable actors implementation

We want to be able to pick them (remove them from the dungeon and put them in our inventory), and use them (whatever that means) :

class Pickable {
public :
   bool pick(Actor *owner, Actor *wearer);
   virtual bool use(Actor *owner, Actor *wearer);
};

Obviously, the picking action is the same for every object whereas using depends on the object type. That's why the use function is virtual. Every function has a parameter for the Actor containing the Pickable feature (the owner) and the Actor that picks/uses the Pickable (for now, this will always be the player).

The pick function checks that the wearer has a container and tries to add the pickable actor to the wearer's container. It returns true if it worked, false if the container is full.

bool Pickable::pick(Actor *owner, Actor *wearer) {
   if ( wearer->container && wearer->container->add(owner) ) {
       engine.actors.remove(owner);
       return true;
   }
   return false;
}

Even though the use function depends on the type of item, the part where we destroy the used object might be used by most of them so we implement it in the base class :

bool Pickable::use(Actor *owner, Actor *wearer) {
   if ( wearer->container ) {
       wearer->container->remove(owner);
       delete owner;
       return true;
   }
   return false;
}

An item cannot always be used. For example, we'll keep the player from drinking a health potion if he already has all his health points. That's why the use function returns a boolean.

Ok now that we have some abstract pickable item, let's implement some real item : the health potion. This is simply a class extending Pickable and overloading the use function to heal the wearer. We're not using HealthPotion as class name because it's more generic. It can be used by most food items, healing scrolls and whatever may give HP to the player.

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

   Healer(float amount);
   bool use(Actor *owner, Actor *wearer);
};

Guess what the constructor implementation is ?

Healer::Healer(float amount) : amount(amount) {
}

Now the use function :

bool Healer::use(Actor *owner, Actor *wearer) {
   if ( wearer->destructible ) {
       float amountHealed = wearer->destructible->heal(amount);
       if ( amountHealed > 0 ) {
           return Pickable::use(owner,wearer);
       }
   }
   return false;
}

First we check that the wearer is destructible (thus can be healed). Then we try to heal it. If it worked, we use the Pickable::use function to remove the health potion from the inventory and destroy it.

Now all we have to do is disseminate some health potions on the map. Let's add a addItem function in Map.hpp :

void addItem(int x, int y);

And the implementation :

static const int MAX_ROOM_ITEMS = 2;
void Map::addItem(int x, int y) {
   Actor *healthPotion=new Actor(x,y,'!',"health potion",
       TCODColor::violet);
   healthPotion->blocks=false;
   healthPotion->pickable=new Healer(4);
   engine.actors.push(healthPotion);
}

And we add another loop in Map::createRoom :

// add items
int nbItems=rng->getInt(0,MAX_ROOM_ITEMS);
while (nbItems > 0) {
   int x=rng->getInt(x1,x2);
   int y=rng->getInt(y1,y2);
   if ( canWalk(x,y) ) {
       addItem(x,y);
   }
   nbItems--;
}

Interface with the player

Now we need some key shortcut to pick items on the ground. We add a handleKeyAction method to PlayerAi to handle all non movement key presses :

Ai.hpp :

void handleActionKey(Actor *owner, int ascii);

In PlayerAi::update, we call this method when the key is a non special key :

case TCODK_RIGHT : dx=1; break;
case TCODK_CHAR : handleActionKey(owner, engine.lastKey.c); break;
default:break;

The implementation scans the actors list and look for pickable actors on the player's tile.

void PlayerAi::handleActionKey(Actor *owner, int ascii) {
   switch(ascii) {
       case 'g' : // pickup item
       {
           bool found=false;
           for (Actor **iterator=engine.actors.begin();
               iterator != engine.actors.end(); iterator++) {
               Actor *actor=*iterator;
               if ( actor->pickable && actor->x == owner->x && actor->y == owner->y ) {

Then it tries to pick the item (it might fail if the inventory is full) :

       if (actor->pickable->pick(actor,owner)) {
           found=true;
           engine.gui->message(TCODColor::lightGrey,"You pick up the %s.",
               actor->name);
           break;
       } else if (! found) {
           found=true;
           engine.gui->message(TCODColor::red,"Your inventory is full.");
       }
   }
}
if (!found) {
   engine.gui->message(TCODColor::lightGrey,"There's nothing here that you can pick up.");
}

The found boolean serves two purposes here :

  • display a message if no pickable item was found.
  • display a message if the inventory is full (but only once).

Something very important : we only pick the first item up, then break from the loop. Why ? Picking the item removes the actor from the actors list. When we pick the last actor of the list, the iterator will go beyond the list's end() pointer. If we keep looping, the game will crash.

We could fix this by replacing the loop ending condition

iterator != engine.actors.end()

with

iterator < engine.actors.end()

but I find it nice and more realistic to have to spend one turn for each item to pick. Moreover, this only concerns tiles where there are more than one item. This shouldn't happen too often. Whether we pick an item or not, we allow the monsters to update. This means that even trying to pick something on an empty tile will trigger a new turn :

           engine.gameStatus=Engine::NEW_TURN;
       }
       break;
   }
}

There one last thing we need to change. So far, we display the corpses present on the player's tile in the message log. Let's change this to display also items (or rather pickable actors). This is in PlayerAi::moveOrAttack :

// look for corpses or items
for (Actor **iterator=engine.actors.begin();
   iterator != engine.actors.end(); iterator++) {
   Actor *actor=*iterator;
   bool corpseOrItem=(actor->destructible && actor->destructible->isDead())
       || actor->pickable;
   if ( corpseOrItem
        && actor->x == targetx && actor->y == targety ) {
       engine.gui->message(TCODColor::lightGrey,"There's a %s here.",actor->name);
   }
}

Now you can compile and pick all the health potions ! But you can't use them yet.

Last but not least, the inventory

We will use the 'i' key to display the player inventory. Each item will have a shortcut that will allow to use it. Let's add a protected function in PlayerAi to select an item from the inventory :

Actor *choseFromInventory(Actor *owner);

If the player presses a key that is not an item shortcut, the function returns NULL.

The function defines some constant for the inventory screen and creates an offscreen console :

Actor *PlayerAi::choseFromInventory(Actor *owner) {
   static const int INVENTORY_WIDTH=50;
   static const int INVENTORY_HEIGHT=28;
   static TCODConsole con(INVENTORY_WIDTH,INVENTORY_HEIGHT);

Note that thanks to the static keyword, the console is only created the first time, and not every time we open the inventory. The inventory has a capacity of 26 items but the console's height is 28 because we draw a frame around it. We use some libtcod helper function to do that :

// display the inventory frame
con.setDefaultForeground(TCODColor(200,180,50));
con.printFrame(0,0,INVENTORY_WIDTH,INVENTORY_HEIGHT,true,
   TCOD_BKGND_DEFAULT,"inventory");

Then we scan the player's inventory and print every item with its shortcut :

// display the items with their keyboard shortcut
con.setDefaultForeground(TCODColor::white);
int shortcut='a';
int y=1;
for (Actor **it=owner->container->inventory.begin();
   it != owner->container->inventory.end(); it++) {
   Actor *actor=*it;
   con.print(2,y,"(%c) %s", shortcut, actor->name);
   y++;
   shortcut++;
}

'a' is a char, but a char in C is nothing else than an integer ascii value, so we can increment it as any integer value.

Then we blit the inventory console on the root console, and call the flush function to make sure that it is visible on the game window :

// blit the inventory console on the root console
TCODConsole::blit(&con, 0,0,INVENTORY_WIDTH,INVENTORY_HEIGHT,
   TCODConsole::root, engine.screenWidth/2 - INVENTORY_WIDTH/2,
   engine.screenHeight/2-INVENTORY_HEIGHT/2);
TCODConsole::flush();

Then we wait for the player to press a key :

// wait for a key press
TCOD_key_t key;
TCODSystem::waitForEvent(TCOD_EVENT_KEY_PRESS,&key,NULL,true);

Since the items shortcuts range from 'a' to 'z', we only deal with TCODK_CHAR values :

if ( key.vk == TCODK_CHAR ) {
   int actorIndex=key.c - 'a';

Now we did something strange. A character subtraction. Well if you subtract the ascii code of the pressed key (say 'b') with the ascii code of 'a', you get the item number in the inventory ('b' - 'a' == 1). Of course, if the player presses a key out of the 'a'-'z' range, the index will not be correct so we add another check :

if ( actorIndex >= 0 && actorIndex < owner->container->inventory.size() ) {
   return owner->container->inventory.get(actorIndex);
}

If no valid item was selected, return NULL :

   }
   return NULL;
}

Now that we can pick an item, we can handle the 'i' key in the handleActionKey function :

case 'i' : // display inventory
{
   Actor *actor=choseFromInventory(owner);
   if ( actor ) {
       actor->pickable->use(actor,owner);
       engine.gameStatus=Engine::NEW_TURN;
   }
}
break;

Again, using an item consumes a game turn, even if Pickable::use returns false. For exemple, trying to drink a health potion while having all your health points will consume a turn.

That's it. You can compile and try to kill all the monsters. For the first time, you have a chance of success !

Personal tools