Complete roguelike tutorial using C++ and libtcod - extra 5: more generic items

From RogueBasin
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.


This article is an optional "extra" that implements more generic items, making it possible to create new items with less code. It can be applied on article 9 source code.

In article 9, we brought more variety to the items, but each new item required a new class inheriting from Pickable. Yet, we can see some pattern in the way pickable actors behave :

They all select one or several targets :

  • the wearer of the item (health potion)
  • the wearer's closest enemy (lightning bolt)
  • a selected actor (confusion)
  • all actors close to a selected tile (fireball)

A fifth way that is not yet used but might be useful would be :

  • all actors close to the wearer of the item

They all apply some effect to the selected targets :

  • increase or decrease health (all but confusion)
  • temporary replace the target's Ai (confusion)

So instead of implementing the target selection and the effect in a class inheriting from Pickable, why not add a TargetSelector and an Effect directly to the Pickable class ?

Contents

A generic target selector

All target selection algorithms but the one selecting the wearer use a range. So we create a class with an enum for the algorithm and a range field.

class TargetSelector {
public :
   enum SelectorType {
       CLOSEST_MONSTER,
       SELECTED_MONSTER,
       WEARER_RANGE,
       SELECTED_RANGE      
   };
   TargetSelector(SelectorType type, float range);
   void selectTargets(Actor *wearer, TCODList<Actor *> & list);
protected :
   SelectorType type;
   float range;
};

There's no WEARER selector type. Well we'll simply assume that if the pickable has no TargetSelector, the effect applies to the wearer.

As usual, the constructor is trivial :

TargetSelector::TargetSelector(SelectorType type, float range) 
   : type(type), range(range) {
}

The selectTargets method populate the list with all selected actors, depending on the algorithm. CLOSEST_MONSTER grabs the monster closest to the wearer :

void TargetSelector::selectTargets(Actor *wearer, TCODList<Actor *> & list) {
   switch(type) {
       case CLOSEST_MONSTER :
       {
           Actor *closestMonster=engine.getClosestMonster(wearer->x,wearer->y,range);
           if ( closestMonster ) {
               list.push(closestMonster);
           }
       }
       break;

SELECTED_MONSTER asks the player to chose an actor (possibly the player himself) :

case SELECTED_MONSTER :
{ 
   int x,y;
   engine.gui->message(TCODColor::cyan, "Left-click to select an enemy,\nor right-click to cancel.");
   if ( engine.pickATile(&x,&y,range)) {
       Actor *actor=engine.getActor(x,y);
       if ( actor ) {
           list.push(actor);
       }
   }
}
break;

WEARER_RANGE picks all actors close enough from the wearer (excluding the wearer himself) :

case WEARER_RANGE :
   for (Actor **iterator=engine.actors.begin();
           iterator != engine.actors.end(); iterator++) {
       Actor *actor=*iterator;
       if ( actor != wearer && actor->destructible && !actor->destructible->isDead()
           && actor->getDistance(wearer->x,wearer->y) <= range) {              
           list.push(actor);
       }
   }
break;

and SELECTED_RANGE asks the player to pick a tile, then selects all actors close enough from this tile (possibly including the player)  :

case SELECTED_RANGE :
   int x,y;
   engine.gui->message(TCODColor::cyan, "Left-click to select a tile,\nor right-click to cancel.");
   if ( engine.pickATile(&x,&y)) {
       for (Actor **iterator=engine.actors.begin();
               iterator != engine.actors.end(); iterator++) {
           Actor *actor=*iterator;
           if ( actor->destructible && !actor->destructible->isDead()
               && actor->getDistance(x,y) <= range) {                
               list.push(actor);
           }
       }
   }
break;

Finally, if no monster has been selected, we display a message :

   }
   if ( list.isEmpty() ) {
       engine.gui->message(TCODColor::lightGrey,"No enemy is close enough");
   }
}

Effects

Now that we can pick targets using a generic class, let's see what happens to those targets when the item is used. First things first, let's define an abstract effect class :

class Effect {
public :
   virtual bool applyTo(Actor *actor) = 0;
};

The method returns false if the effect could not be applied (for example healing someone who is not wounded).

Health effect

Now let's define the effect that modify the health points :

class HealthEffect : public Effect {
public :
   float amount;
   const char *message;

   HealthEffect(float amount, const char *message);
   bool applyTo(Actor *actor); 
};

The effect is healing the target if amount is positive or hurting him if amount is negative. The message makes it possible to display some custom message. To keep things simple, we suppose that the message string always contains a parameter for the target name (%s) and a parameter for the amount (%g). But you can use NULL if you don't want a message to be displayed.

The applyTo function first checks that we're dealing with a destructible actor :

HealthEffect::HealthEffect(float amount, const char *message) 
   : amount(amount), message(message) {
}

bool HealthEffect::applyTo(Actor *actor) {
   if (!actor->destructible) return false;

Then deals with the healing part :

if ( amount > 0 ) {
   float pointsHealed=actor->destructible->heal(amount);
   if (pointsHealed > 0) {
       if ( message ) {
           engine.gui->message(TCODColor::lightGrey,message,actor->name,pointsHealed);
       }
       return true;
   }

We display the message only if some health points were actually restored. Now the hurting part :

   } else {
           if ( message && -amount-actor->destructible->defense > 0 ) {
               engine.gui->message(TCODColor::lightGrey,message,actor->name,
                   -amount-actor->destructible->defense);
           }
           if ( actor->destructible->takeDamage(actor,-amount) > 0 ) {
               return true;
           }
   }

Here we have to display the message before calling takeDamage because after, the actor might already be dead. You wouldn't want to read "the dead orc get burnt for x hit points".

If the target could not be healed/wounded, we return false :

   return false;
}

Some class to officialize temporary Ai

The second effect has to temporary replace the target's Ai. It can only be replaced by an Ai class that is capable of saving the previous Ai to get back to it once the effect ends. Let's improve the Ai class hierarchy by defining a TemporaryAi abstract class :

class TemporaryAi : public Ai {
public :
   TemporaryAi(int nbTurns);
   void update(Actor *owner);
   void applyTo(Actor *actor);
protected :
   int nbTurns;
   Ai *oldAi;
};

The TemporaryAi class stores the number of turns and the previous Ai. The implementation is trivial as it mostly reuses code from the ConfusedMonsterAi class :

TemporaryAi::TemporaryAi(int nbTurns) : nbTurns(nbTurns) {
}

void TemporaryAi::update(Actor *owner) {
   nbTurns--;
   if ( nbTurns == 0 ) {
       owner->ai = oldAi;
       delete this;
   }
}

void TemporaryAi::applyTo(Actor *actor) {
   oldAi=actor->ai;
   actor->ai=this;
}

Now we can simplify the ConfusedMonsterAi class by making it inherit from TemporaryAi :

class ConfusedMonsterAi : public TemporaryAi {
public :
   ConfusedMonsterAi(int nbTurns);
   void update(Actor *owner);
};
ConfusedMonsterAi::ConfusedMonsterAi(int nbTurns) 
   : TemporaryAi(nbTurns) {
}

void ConfusedMonsterAi::update(Actor *owner) {
   TCODRandom *rng=TCODRandom::getInstance();
   int dx=rng->getInt(-1,1);
   int dy=rng->getInt(-1,1);
   if ( dx != 0 || dy != 0 ) {
       int destx=owner->x+dx;
       int desty=owner->y+dy;
       if ( engine.map->canWalk(destx, desty) ) {
           owner->x = destx;
           owner->y = desty;
       } else {
           Actor *actor=engine.getActor(destx, desty);
           if ( actor ) {
               owner->attacker->attack(owner, actor);
           }
       }
   }
   TemporaryAi::update(owner);
}

An effect that changes the Ai

We can know define our second effect :

class AiChangeEffect : public Effect {
public :
   TemporaryAi *newAi;
   const char *message;

   AiChangeEffect(TemporaryAi *newAi, const char *message);
   bool applyTo(Actor *actor);
};

Once again, it's a very simple class :

AiChangeEffect::AiChangeEffect(TemporaryAi *newAi, const char *message) 
   : newAi(newAi), message(message) {
}

bool AiChangeEffect::applyTo(Actor *actor) {
   newAi->applyTo(actor);
   if ( message ) {
       engine.gui->message(TCODColor::lightGrey,message,actor->name);
   }
   return true;
}

A more generic Pickable class

It's time to use our new TargetSelector and Effect classes to improve the Pickable class.

class Pickable {
public :
   Pickable(TargetSelector *selector, Effect *effect);
   virtual ~Pickable();
   bool pick(Actor *owner, Actor *wearer);
   void drop(Actor *owner, Actor *wearer);
   bool use(Actor *owner, Actor *wearer);
protected :
   TargetSelector *selector;
   Effect *effect;
};

The class will store a TargetSelector (NULL if the target is the wearer) and an Effect. We add a destructor to delete those fields when the Pickable is destroyed.

Pickable::Pickable(TargetSelector *selector, Effect *effect) :
   selector(selector), effect(effect) {
}

Pickable::~Pickable() {
   if ( selector ) delete selector;
   if ( effect ) delete effect;
}

Now the use function determines the list of targets using the selector :

bool Pickable::use(Actor *owner, Actor *wearer) {
   TCODList<Actor *> list;
   if ( selector ) {
       selector->selectTargets(wearer, list);
   } else {
       list.push(wearer);
   }

Then tries to apply the effect to the targets. We consider that the action is a success if the effect succeeds on at least one of the targets.

bool succeed=false;
for (Actor **it=list.begin(); it!=list.end(); it++) {
   if ( effect->applyTo(*it) ) {
       succeed=true;
   }
}

In case of a success, we destroy the item :

   if ( succeed ) {
       if ( wearer->container ) {
           wearer->container->remove(owner);
           delete owner;
       }
   }
   return succeed; 
}

With this new powerful Pickable class, you can now safely remove all the previous item classes from the code :

  • Healer
  • Confuser
  • LightningBolt
  • Fireball

Updating the Map::addItem function

All the items now rely on the Pickable class, using appropriate target selectors and effects.

The health potion :

// create a health potion
Actor *healthPotion=new Actor(x,y,'!',"health potion",
   TCODColor::violet);
healthPotion->blocks=false;
healthPotion->pickable=new Pickable(NULL,new HealthEffect(4,NULL));
engine.actors.push(healthPotion);

The scroll of lightning bolt :

// create a scroll of lightning bolt 
Actor *scrollOfLightningBolt=new Actor(x,y,'#',"scroll of lightning bolt",
   TCODColor::lightYellow);
scrollOfLightningBolt->blocks=false;
scrollOfLightningBolt->pickable=new Pickable(
   new TargetSelector(TargetSelector::CLOSEST_MONSTER,5),
   new HealthEffect(-20,"A lighting bolt strikes the %s with a loud thunder!\n"
       "The damage is %g hit points."));
engine.actors.push(scrollOfLightningBolt);

The scroll of fireball :

// create a scroll of fireball
Actor *scrollOfFireball=new Actor(x,y,'#',"scroll of fireball",
   TCODColor::lightYellow);
scrollOfFireball->blocks=false;
scrollOfFireball->pickable=new Pickable(
   new TargetSelector(TargetSelector::SELECTED_RANGE,3),
   new HealthEffect(-12,"The %s gets burned for %g hit points."));
engine.actors.push(scrollOfFireball);

The scroll of confusion :

// create a scroll of confusion
Actor *scrollOfConfusion=new Actor(x,y,'#',"scroll of confusion",
   TCODColor::lightYellow);
scrollOfConfusion->blocks=false;
scrollOfConfusion->pickable=new Pickable(
   new TargetSelector(TargetSelector::SELECTED_MONSTER,5),
   new AiChangeEffect(new ConfusedMonsterAi(10),
       "The eyes of the %s look vacant,\nas he starts to stumble around!"));
engine.actors.push(scrollOfConfusion);

That's it. You can now compile and well... the game is almost exactly the same. We lost some details in the process (for example having specific colors for the messages but we could easily add a TCODColor parameter to the effects' constructors), but now, you can add a new category of item we only a few lines of code, by either mixing existing selectors and effect or just adding a new effect.

Personal tools