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

From RogueBasin
Jump to navigation Jump to 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

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.