Complete roguelike tutorial using C++ and libtcod - part 11: dungeon levels and character progression

From RogueBasin
Revision as of 21:31, 17 December 2018 by Anonymous (talk | contribs) (Fix a typo in a note about a relevant API change that breaks some tutorial code)
Jump to navigation Jump to search
The printable version is no longer supported and may have rendering errors. Please update your browser bookmarks and please use the default browser print function instead.
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 turn the single level prototype into a real game by adding multi-level dungeons (in fact infinite levels) and experience level for the player.

View source here

Stairs

Obviously, the first thing we need is some stairs to go deeper into the dungeon. Where as other actors are only displayed when they are in the field of view, we would like the stairs to be always visible. We need another boolean on the Actor class for this :

bool blocks; // can we walk on this actor?
bool fovOnly; // only display when in fov
Attacker *attacker; // something that deals damages

Of course this field must be initialized in the 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),fovOnly(true),attacker(NULL),destructible(NULL),ai(NULL),
   pickable(NULL),container(NULL) {
}

The engine rendering code must be updated to use this new field :

// draw the actors
for (Actor **iterator=actors.begin();
   iterator != actors.end(); iterator++) {
   Actor *actor=*iterator;
   if ( actor != player 
       && ((!actor->fovOnly && map->isExplored(actor->x,actor->y))
           || map->isInFov(actor->x,actor->y)) ) {
       actor->render();
   }
}

Now we can create the stairs. But we need to keep a pointer on them to be able to detect when the player stands on their cell, so let's add an Actor pointer in the Engine class :

Actor *player;
Actor *stairs;
Map *map;

We create the stair in the Engine::init function :

actors.push(player);
stairs = new Actor(0,0,'>',"stairs",TCODColor::white);
stairs->blocks=false;
stairs->fovOnly=false;
actors.push(stairs);
map = new Map(80,43);

In the end of Map::createRoom, we put the stair in the middle of the room. This will be called for every room in the dungeon. In the end, the stairs will be in the last room of the BSP tree, far from the player who is in the first room.

// set stairs position
engine.stairs->x=(x1+x2)/2;
engine.stairs->y=(y1+y2)/2;

The stairs must be saved along with other actors in Engine::save :

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

And restored in Engine::load :

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

For this tutorial, we create one-way stairs. Once you go down, you can't go back up. But you can improve this by adding stairs going up. You don't even need a new class, you can use the actor character (either > or <) to detect the type of stair.

When the player is on the stair, he can push '>' to go down to the next level. We check this in PlayerAi::handleActionKey :

case '>' :
   if ( engine.stairs->x == owner->x && engine.stairs->y == owner->y ) {
       engine.nextLevel();
   } else {
       engine.gui->message(TCODColor::lightGrey,"There are no stairs here.");
   }
break;

Note that this seems not to work anymore as lastKey.c is always lowercase. In that case check lastKey.shift.

The Engine::nextLevel function deals with the dungeon regeneration. We'll keep the current level number in a field of the Engine.

Engine.hpp:

int level;
void nextLevel();

This level starts with 1 :

Engine::Engine(int screenWidth, int screenHeight) : gameStatus(STARTUP),
   player(NULL),map(NULL),fovRadius(10),
   screenWidth(screenWidth),screenHeight(screenHeight),level(1) {

and is increased in nextLevel() :

void Engine::nextLevel() {
   level++;

We also display some messages and heal the player :

gui->message(TCODColor::lightViolet,"You take a moment to rest, and recover your strength.");
player->destructible->heal(player->destructible->maxHp/2);
gui->message(TCODColor::red,"After a rare moment of peace, you descend\ndeeper into the heart of the dungeon...");

Now we clean the dungeon, removing all the monsters and items, but not the player. There's also no point deleting and recreating the stairs :

   delete map;
   // delete all actors but player and stairs
   for (Actor **it=actors.begin(); it!=actors.end(); it++) {
       if ( *it != player && *it != stairs ) {
           delete *it;
           it = actors.remove(it);
       }
   }

We're using a special function of TCODList here that makes it possible to remove an element from the list while iterating over it. It's very important to use the remove function that takes the iterator as parameter and not the function that takes a list element. Doing this :

actors.remove(*it)

on the last element of the list would result in it getting bigger than actors.end(). The loop would keep rolling until a SIGSEGV occurs (see the extra article about debugging).

Another very important thing when iterating over a TCODList : never add an element to the list inside the loop. Adding an element might result in a reallocation of the list. The iterator value would not be correct anymore after that. If you need to add an element to the list, create another toAdd list, fill it with the elements to be added. Once you finished iterating over the first list, call myList.addAll(toAdd).

Back to the nextLevel function, we can now recreate a map (including actors) :

   // create a new map
   map = new Map(80,43);
   map->init(true);
   gameStatus=STARTUP;
}

One last thing we want to do is to display the dungeon level, in Gui::render, just before blitting the gui console :

// dungeon level
con->setDefaultForeground(TCODColor::white);
con->print(3,3,"Dungeon level %d",engine.level);

Rewarding monster kills

We need some xp value on the creatures. When the player kills a creature, the creature's xp is added the player's one. We put this in the Destructible class, so that both creatures and the player get it :

Destructible.hpp :

int xp; // XP gained when killing this monster (or player xp)

Destructible(float maxHp, float defense, const char *corpseName, int xp);

Destructible.cpp :

Destructible::Destructible(float maxHp, float defense, const char *corpseName, int xp) :
   maxHp(maxHp),hp(maxHp),defense(defense),corpseName(corpseName),xp(xp) {
}

We also add the xp value to the monster constructor :

Destructible.hpp :

MonsterDestructible(float maxHp, float defense, const char *corpseName, int xp);

Destructible.cpp :

MonsterDestructible::MonsterDestructible(float maxHp, float defense, const char *corpseName,int xp) :
   Destructible(maxHp,defense,corpseName,xp) {
}

Now we change the MonsterDestructible::die function to handle the xp :

void MonsterDestructible::die(Actor *owner) {
   // transform it into a nasty corpse! it doesn't block, can't be
   // attacked and doesn't move
   engine.gui->message(TCODColor::lightGrey,"%s is dead. You gain %d xp",
       owner->name, xp);
   engine.player->destructible->xp += xp;
   Destructible::die(owner);
}

Player's XP level

Now we need a way to associate some XP level with the player's xp value. Each time the player reaches a new level, he will get some candies.

We add an xpLevel field in the PlayerAi class, a constructor to initialize it and some helper function to compute the xp value for the next xp level :

Ai.hpp :

class PlayerAi : public Ai {
public :
   int xpLevel;
   PlayerAi();
   int getNextLevelXp();

Ai.cpp :

PlayerAi::PlayerAi() : xpLevel(1) {
}

const int LEVEL_UP_BASE=200;
const int LEVEL_UP_FACTOR=150;

int PlayerAi::getNextLevelXp() {
   return LEVEL_UP_BASE + xpLevel*LEVEL_UP_FACTOR;
}

The player starts at level 1. Level 2 requires 350 xp, then every new level requires 150 more xp than the previous. We can update the xp level in the PlayerAi::update function :

void PlayerAi::update(Actor *owner) {
   int levelUpXp = getNextLevelXp();
   if ( owner->destructible->xp >= levelUpXp ) {
       xpLevel++;
       owner->destructible->xp -= levelUpXp;
       engine.gui->message(TCODColor::yellow,"Your battle skills grow stronger! You reached level %d",xpLevel);
   }
   if ( owner->destructible && owner->destructible->isDead() ) {
       return;
   }

Another change in Gui::render() to display the player xp bar :

// draw the XP bar
PlayerAi *ai=(PlayerAi *)engine.player->ai;
char xpTxt[128];
sprintf(xpTxt,"XP(%d)",ai->xpLevel);
renderBar(1,5,BAR_WIDTH,xpTxt,engine.player->destructible->xp,
   ai->getNextLevelXp(),
   TCODColor::lightViolet,TCODColor::darkerViolet);

Now we also want the player to chose some upgrade when he reaches a new xp level. For that, we need to improve the menu class.

Distributing candies

Now we can finally ask the player what he wants to improve when he reaches a new xp level. Let's define some new menu items in Gui.hpp:

enum MenuItemCode {

   NONE,
   NEW_GAME,
   CONTINUE,
   EXIT,
   CONSTITUTION,
   STRENGTH,
   AGILITY
};

In PlayerAi::update, we build a custom menu and use the pick function :

void PlayerAi::update(Actor *owner) {
   int levelUpXp = getNextLevelXp();
   if ( owner->destructible->xp >= levelUpXp ) {
       xpLevel++;
       owner->destructible->xp -= levelUpXp;
       engine.gui->message(TCODColor::yellow,"Your battle skills grow stronger! You reached level %d",xpLevel);
       engine.gui->menu.clear();
       engine.gui->menu.addItem(Menu::CONSTITUTION,"Constitution (+20HP)");
       engine.gui->menu.addItem(Menu::STRENGTH,"Strength (+1 attack)");
       engine.gui->menu.addItem(Menu::AGILITY,"Agility (+1 defense)");
       Menu::MenuItemCode menuItem=engine.gui->menu.pick(Menu::PAUSE);

Then we update the player's stats depending on what was chosen :

switch (menuItem) {
   case Menu::CONSTITUTION :
       owner->destructible->maxHp+=20;
       owner->destructible->hp+=20;
       break;
   case Menu::STRENGTH :
       owner->attacker->power += 1;
       break;
   case Menu::AGILITY :
       owner->destructible->defense += 1;
       break;
   default:break;
}

A fancy pause menu

So far, the in-game pause menu looks like the main game menu. That's not something very usual. The pause menu is generally rendered on top of the game screen.

We're going to add some displayMode parameter to the menu class to enable an alternative rendering method to get this look :

The display mode is defined in Gui.hpp :

class Menu {
public :
   enum MenuItemCode {
       NONE,
       NEW_GAME,
       CONTINUE,
       EXIT
   };
   enum DisplayMode {
       MAIN,
       PAUSE
   };
   ~Menu();
   void clear();
   void addItem(MenuItemCode code, const char *label);
   MenuItemCode pick(DisplayMode mode=MAIN);

Now the implementation, in Gui.cpp. First we define the size of the pause menu using some constants :

const int PAUSE_MENU_WIDTH=30;
const int PAUSE_MENU_HEIGHT=15;
Menu::MenuItemCode Menu::pick(DisplayMode mode) {

Since the menu position depends on the display mode, we define two variables to store the menu position :

int selectedItem=0;
int menux,menuy;

When we render the pause menu, we want to center the menu on the screen :

if (mode == PAUSE) {
   menux=engine.screenWidth/2-PAUSE_MENU_WIDTH/2;
   menuy=engine.screenHeight/2-PAUSE_MENU_HEIGHT/2;

Then we use the printFrame helper function to draw an empty dialog box. We use the TCOD_BKGND_ALPHA flag so that the background is transparent. The value 70 means alpha is 70/255 = 0.27. The dialog is almost opaque and let us just see a bit of the background.

TCODConsole::root->setDefaultForeground(TCODColor(200,180,50));
TCODConsole::root->printFrame(menux,menuy,PAUSE_MENU_WIDTH,PAUSE_MENU_HEIGHT,true,
   TCOD_BKGND_ALPHA(70),"menu");       

Then we slightly offset the position of the menu so that it doesn't render on top of the dialog frame :

menux+=2;
menuy+=3;

If we're rendering the main menu, we just display the background image and define the menu position :

} else {
   static TCODImage img("menu_background1.png");
   img.blit2x(TCODConsole::root,0,0);
   menux=10;
   menuy=TCODConsole::root->getHeight()/3;
}

Then we just have to update the menu render code to use the menux, menuy variables :

while( !TCODConsole::isWindowClosed() ) {
   int currentItem=0;
   for (MenuItem **it=items.begin(); it!=items.end(); it++) {
       if ( currentItem == selectedItem ) {
           TCODConsole::root->setDefaultForeground(TCODColor::lighterOrange);
       } else {
           TCODConsole::root->setDefaultForeground(TCODColor::lightGrey);
       }
       TCODConsole::root->print(menux,menuy+currentItem*3,(*it)->label);
       currentItem++;
   }