Complete roguelike tutorial using C++ and libtcod - part 9: spells and ranged combat

From RogueBasin
Revision as of 20:17, 20 December 2015 by Joel Pera (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'll start to add diversity to the gameplay by adding three new spells :

  • lightning bolt : deals damages to the closest enemy
  • fireball : powerful area effect spell
  • confusion : turns the selected enemy into a randomly wandering zombie for a few turns
View source here

Contents

The scroll of lightning bolt

This dreadful spell will deal 20 damage to the closest monster, up to a distance of 5 tiles. So the first thing we need is some helper functions to get the closest monster within a range.

Some helpers

The easiest is a function to get the distance from an actor to a specific tile of the map :

Actor.hpp

float getDistance(int cx, int cy) const;

Actor.cpp

float Actor::getDistance(int cx, int cy) const {
   int dx=x-cx;
   int dy=y-cy;
   return sqrtf(dx*dx+dy*dy);
}

Well that was a small warmup. Don't forget to include math.h for the sqrtf function. Now let's do something manlier.

Engine.hpp

Actor *getClosestMonster(int x, int y, float range) const;

This function returns the closest monster from position x,y within range. If range is 0, it's considered infinite. If no monster is found within range, it returns NULL.

Engine.cpp

Actor *Engine::getClosestMonster(int x, int y, float range) const {
   Actor *closest=NULL;
   float bestDistance=1E6f;

First we declare some variable. closest is the closest monster found so far. bestDistance is the distance for the closest monster found so far. We initialize it with a value higher than any possible value (1E6 == 1000000) so that the first monster in the range will be a candidate. So let's iterate over the actors list and check alive monsters :

for (Actor **iterator=actors.begin();
   iterator != actors.end(); iterator++) {
   Actor *actor=*iterator;
   if ( actor != player && actor->destructible 
       && !actor->destructible->isDead() ) {
Now let's check if the guy is within range and closer than what we found so far.
           float distance=actor->getDistance(x,y);
           if ( distance < bestDistance && ( distance <= range || range == 0.0f ) ) {
               bestDistance=distance;
               closest=actor;
           }
       }
   }
   return closest;
}

LightningBolt Pickable

Now we can create the new Pickable :

Pickable.hpp

class LightningBolt: public Pickable {
public :
   float range,damage;
   LightningBolt(float range, float damage);
   bool use(Actor *owner, Actor *wearer);
};

As usual, the constructor is trivial :

LightningBolt::LightningBolt(float range, float damage)
   : range(range),damage(damage) {
}

The implementation tries to find a monster within range :

bool LightningBolt::use(Actor *owner, Actor *wearer) {
   Actor *closestMonster=engine.getClosestMonster(wearer->x,wearer->y,range);
   if (! closestMonster ) {
       engine.gui->message(TCODColor::lightGrey,"No enemy is close enough to strike.");
       return false;
   }

Remember that the owner is the actor that contains the LightningBolt (the scroll of lightning bolt) while the wearer is the actor having the owner in its inventory. If we found a monster, we display a appropriate message and deal damage.

// hit closest monster for <damage> hit points
engine.gui->message(TCODColor::lightBlue,
   "A lighting bolt strikes the %s with a loud thunder!\n"
   "The damage is %g hit points.",
   closestMonster->name,damage);
closestMonster->destructible->takeDamage(closestMonster,damage);

And don't forget to call the father's class use method to consume the item and remove it from the inventory :

   return Pickable::use(owner,wearer);
}

Updating the map

That's it. We only need to change the Map::addItem function to put a few scrolls of lightning bolt here and there :

void Map::addItem(int x, int y) {
   TCODRandom *rng=TCODRandom::getInstance();
   int dice = rng->getInt(0,100);
   if ( dice < 70 ) {
       // create a health potion
       Actor *healthPotion=new Actor(x,y,'!',"health potion",
           TCODColor::violet);
       healthPotion->blocks=false;
       healthPotion->pickable=new Healer(4);
       engine.actors.push(healthPotion);
   } else if ( dice < 70+10 ) {
       // create a scroll of lightning bolt 
       Actor *scrollOfLightningBolt=new Actor(x,y,'#',"scroll of lightning bolt",
           TCODColor::lightYellow);
       scrollOfLightningBolt->blocks=false;
       scrollOfLightningBolt->pickable=new LightningBolt(5,20);
       engine.actors.push(scrollOfLightningBolt);
   }
}

70% of the items are health potions. 10% are scrolls of lightning bolt. The remaining 20% are for the 2 other spells.

Now you can compile and start to zap everyone in the dungeon !

The scroll of fireball

This one will add two interesting features : targetting and area effects. Targetting requires to be able to select a position on the map with the mouse.

Targetting helper

Let's create a helper function for that in the Engine class.

Engine.hpp

bool pickATile(int *x, int *y, float maxRange = 0.0f);

The function returns a boolean to allow the player to cancel by pressing a key or right clicking. A range of 0 means that we allow the tile to be picked anywhere in the player's field of view. This function uses a default value for the maxRange parameter so that we can omit the parameter :

engine.pickATile(&x,&y);

is the same as

engine.pickATile(&x,&y, 0.0f);

We're not going to use the main loop from main.cpp while picking a tile. This would require to add a flag in the engine to know if we're in standard play mode or tile picking mode. Instead, we create a alternative game loop.

Since we want the mouse look to keep working while targetting, we need to render the game screen in the loop

bool Engine::pickATile(int *x, int *y, float maxRange) {
   while ( !TCODConsole::isWindowClosed() ) {
       render();

Now the player might not be aware of where he's allowed to click. Let's highlight the zone for him. We scan the whole map and look for tiles in FOV and within range :

// highlight the possible range
for (int cx=0; cx < map->width; cx++) {
   for (int cy=0; cy < map->height; cy++) {
       if ( map->isInFov(cx,cy)
           && ( maxRange == 0 || player->getDistance(cx,cy) <= maxRange) ) {

Remember how we darkened the oldest message log by multiplying its color by a float smaller than 1 ? Well we can highlight a color using the same trick :

           TCODColor col=TCODConsole::root->getCharBackground(cx,cy);
           col = col * 1.2f;
           TCODConsole::root->setCharBackground(cx,cy,col);
       }
   }
}

Now we need to update the mouse coordinate in Engine::mouse, so let's duplicate the checkForEvent call from Engine::update :

TCODSystem::checkForEvent(TCOD_EVENT_KEY_PRESS|TCOD_EVENT_MOUSE,&lastKey,&mouse);

We're going to do one more thing to help the player select his tile : fill the tile under the mouse cursor with white :

if ( map->isInFov(mouse.cx,mouse.cy)
   && ( maxRange == 0 || player->getDistance(mouse.cx,mouse.cy) <= maxRange )) {
   TCODConsole::root->setCharBackground(mouse.cx,mouse.cy,TCODColor::white);

And if the player presses the left button while a valid tile is selected, return the tile coordinates :

   if ( mouse.lbutton_pressed ) {
       *x=mouse.cx;
       *y=mouse.cy;
       return true;
   }
} 

If the player pressed a key or right clicked, we exit  :

if (mouse.rbutton_pressed || lastKey.vk != TCODK_NONE) {
   return false;
}

Finally we flush the screen. If the player exits the loop by closing the game window, we also return false :

       TCODConsole::flush();
   }
   return false;
}

Fireball Pickable

Now that we can pick a tile, we can create the new Pickable. Since it requires a range and a damage amount, we can inherit from the LightnintBolt class :

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

Implementation :

Fireball::Fireball(float range, float damage)
   : LightningBolt(range,damage) {     
}

The use function displays a message and waits for the player to pick a tile :

bool Fireball::use(Actor *owner, Actor *wearer) {
   engine.gui->message(TCODColor::cyan, "Left-click a target tile for the fireball,\nor right-click to cancel.");
   int x,y;
   if (! engine.pickATile(&x,&y)) {
       return false;
   }

If a valid tile was picked, a message is displayed and we start to scan all creatures alive and within range (including the player himself !) :

// burn everything in <range> (including player)
engine.gui->message(TCODColor::orange,"The fireball explodes, burning everything within %g tiles!",range);
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) {

The poor guys get burned :

       engine.gui->message(TCODColor::orange,"The %s gets burned for %g hit points.",
           actor->name,damage);
       actor->destructible->takeDamage(actor,damage);
   }
}

and the scroll disappears :

   return Pickable::use(owner,wearer);
}

Now let's put some scrolls of fireball in the dungeon, at the end of Map::addItem :

} else if ( dice < 70+10+10 ) {
   // create a scroll of fireball
   Actor *scrollOfFireball=new Actor(x,y,'#',"scroll of fireball",
       TCODColor::lightYellow);
   scrollOfFireball->blocks=false;
   scrollOfFireball->pickable=new Fireball(3,12);
   engine.actors.push(scrollOfFireball);

You can compile. With all the new powers you get, monsters may start to fear you.

The scroll of confusion

This one is very special as it will change the behaviour of a monster. For this, we need to be able to pick a specific actor.

Actor picking helper

Let's add some helper in the Engine class.

Engine.hpp

Actor *getActor(int x, int y) const;

Note that the function is called getActor and not getMonster. Yes, you'll be able to confuse yourself ! The function just scans the actors and tries to find someone alive on the specified tile :

Actor *Engine::getActor(int x, int y) const {
   for (Actor **iterator=actors.begin();
       iterator != actors.end(); iterator++) {
       Actor *actor=*iterator;
       if ( actor->x == x && actor->y ==y && actor->destructible
           && ! actor->destructible->isDead()) {
           return actor;
       }
   }
   return NULL;
}

Artificial confusion

We need a new Ai that wanders randomly and attacks anything it bumps into. This Ai have to be able to restore the previous Ai after a few turns :

Ai.hpp

class ConfusedMonsterAi : public Ai {
public :
   ConfusedMonsterAi(int nbTurns, Ai *oldAi);
   void update(Actor *owner);
protected :
   int nbTurns;
   Ai *oldAi;
};

Ai.cpp:

ConfusedMonsterAi::ConfusedMonsterAi(int nbTurns, Ai *oldAi) 
   : nbTurns(nbTurns),oldAi(oldAi) {
}

First we get some random direction.

void ConfusedMonsterAi::update(Actor *owner) {
   TCODRandom *rng=TCODRandom::getInstance();
   int dx=rng->getInt(-1,1);
   int dy=rng->getInt(-1,1);

If there is a movement to a walkable cell, move the monster :

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;

But if there's someone on the destination cell, attack him !

   } else {
       Actor *actor=engine.getActor(destx, desty);
       if ( actor ) {
           owner->attacker->attack(owner, actor);
       }
   }
}

Finally, decrease the number of confusion turns and restore the previous Ai when the last turn has gone.

   nbTurns--;
   if ( nbTurns == 0 ) {
       owner->ai = oldAi;
       delete this;
   }
}

Note that since no Actor will contain the ConfusedMonsterAi reference anymore, we can safely delete it. Each scroll of confusion will create a new instance of ConfusedMonsterAi.

Confuser Pickable

Our last Pickable contains the number of turns of the effect and a range (we don't want to be able to confuse a monster at the other side of the map).

class Confuser : public Pickable {
public :
   int nbTurns;
   float range;
   Confuser(int nbTurns, float range);
   bool use(Actor *owner, Actor *wearer);  
};

The constructor is the most complex thing we've seen so far.

Confuser::Confuser(int nbTurns, float range)
   : nbTurns(nbTurns), range(range) {
}

Ok, I was just kidding.

As with the fireball, the use function first picks a tile :

bool Confuser::use(Actor *owner, Actor *wearer) {
   engine.gui->message(TCODColor::cyan, "Left-click an enemy to confuse it,\nor right-click to cancel.");
   int x,y;
   if (! engine.pickATile(&x,&y,range)) {
       return false;
   }

then checks if there is an actor on the selected tile :

Actor *actor=engine.getActor(x,y);
if (! actor ) {
   return false;
}

And now, time for brain washing :

// confuse the monster for <nbTurns> turns
Ai *confusedAi=new ConfusedMonsterAi( nbTurns, actor->ai );
actor->ai = confusedAi;

Finally, display some message and destroy the scroll :

   engine.gui->message(TCODColor::lightGreen,"The eyes of the %s look vacant,\nas he starts to stumble around!",
       actor->name);
   return Pickable::use(owner,wearer);
}

And we can finally handle the last 10% in Map::addItem :

} else {
   // create a scroll of confusion
   Actor *scrollOfConfusion=new Actor(x,y,'#',"scroll of confusion",
       TCODColor::lightYellow);
   scrollOfConfusion->blocks=false;
   scrollOfConfusion->pickable=new Confuser(10,8);
   engine.actors.push(scrollOfConfusion);

Bonus: drop item

If you're not too impatient to use all the new magical goodies, we can add a last small feature. So far we were able to pick items but not to drop them. Let's add a drop function to the Pickable class :

Pickable.hpp

void drop(Actor *owner, Actor *wearer);

Pickable.cpp

void Pickable::drop(Actor *owner, Actor *wearer) {
   if ( wearer->container ) {
       wearer->container->remove(owner);
       engine.actors.push(owner);
       owner->x=wearer->x;
       owner->y=wearer->y;
       engine.gui->message(TCODColor::lightGrey,"%s drops a %s.",
           wearer->name,owner->name);
   }
}

The only subtlety here is that we have to remember to update the item position before dropping it on the ground else it will reappear on the tile where it was picked !

The input is processed in PlayerAi::handleActionKey :

case 'd' : // drop item 
{
   Actor *actor=choseFromInventory(owner);
   if ( actor ) {
       actor->pickable->drop(actor,owner);
       engine.gameStatus=Engine::NEW_TURN;
   }           
}
break;

Dropping an item also costs you a turn.

Now you can compile and enjoy your new mage powers !

Personal tools