Complete roguelike tutorial using C++ and libtcod - part 7: the GUI

From RogueBasin
Revision as of 18:14, 20 December 2015 by Joel Pera (talk | contribs) (added source link)
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.


After the longish article 6 about melee fighting, we're going to take a break and do something easier.

  • First, we're going to slightly improve the player health bar with some visual fluff.
  • We will also move the game log from the standard output to the game window.
  • Finally, we will start to use the mouse and implement a mouse look feature to display information about what is under the mouse cursor.

These are three very distinct features but for the sake of simplicity, we will stuff everything in a single Gui class (for Graphical User Interface).

View source here

libtcod functions used in this article

TCODConsole::TCODConsole

TCODConsole::blit

TCODConsole::rect

TCODConsole::printEx

A shiny health bar

To make it easier to change the GUI position, we're going to render everything on an offscreen console. So let's create a Gui header with everything we need :

Gui.hpp

class Gui {
public :
   Gui();
   ~Gui();
   void render();

protected :
   TCODConsole *con;

   void renderBar(int x, int y, int width, const char *name,
       float value, float maxValue, const TCODColor &barColor,
       const TCODColor &backColor);
};

We use a constructor to allocate the GUI console, a destructor to delete it. The render function will be called by the Engine and will use the renderBar utility to draw the health bar. Before we start the implementation, let's update the Engine :

Engine.hpp :

int screenWidth;
int screenHeight;
Gui *gui;

Engine(int screenWidth, int screenHeight);

We could have used a non pointer field :

Gui gui;

but as you will see below, we need engine.screenHeight to be initialized when the constructor of Gui is called. That's why we're allocating the Gui field dynamically :

Engine constructor :

map = new Map(80,43);
gui = new Gui();

We're going to use 7 lines for the GUI : one for the mouse look description and 6 for the log. That's why we slightly reduced the map's height from 45 cells to 43.

Of course, don't forget to delete gui in the Engine's destructor :

Engine::~Engine() {
   actors.clearAndDelete();
   delete map;
   delete gui;
}

and call gui->render() in the Engine::render method :

Engine::render :

player->render();
// show the player's stats
gui->render();

Ok now we can start the GUI implementation.

static const int PANEL_HEIGHT=7;
static const int BAR_WIDTH=20;

PANEL_HEIGHT is the height of the GUI console. BAR_WIDTH the width of the player health bar.

The constructor allocates the console, the destructor deletes it :

Gui::Gui() {
   con = new TCODConsole(engine.screenWidth,PANEL_HEIGHT);
}

Gui::~Gui() {
   delete con;
}

First, the render function fills the console with black (to erase the content that was drawn in the previous frame) :

void Gui::render() {
   // clear the GUI console
   con->setDefaultBackground(TCODColor::black);
   con->clear();

Then we draw the health bar using the renderBar function :

// draw the health bar
renderBar(1,1,BAR_WIDTH,"HP",engine.player->destructible->hp,
   engine.player->destructible->maxHp,
   TCODColor::lightRed,TCODColor::darkerRed);

And finally, we blit the console on the game window :

// blit the GUI console on the root console
TCODConsole::blit(con,0,0,engine.screenWidth,PANEL_HEIGHT,
   TCODConsole::root,0,engine.screenHeight-PANEL_HEIGHT);

And now the most important : the renderBar function.

void Gui::renderBar(int x, int y, int width, const char *name,
   float value, float maxValue, const TCODColor &barColor,
   const TCODColor &backColor) {

First, we're filling the bar with the background color :

// fill the background
con->setDefaultBackground(backColor);
con->rect(x,y,width,1,false,TCOD_BKGND_SET);

Then we're computing how much of the bar should be filled with the bar color :

int barWidth = (int)(value / maxValue * width);
if ( barWidth > 0 ) {
   // draw the bar
   con->setDefaultBackground(barColor);
   con->rect(x,y,barWidth,1,false,TCOD_BKGND_SET);
}

We're also writing the values on top of the bar, using the printEx function to center the text :

   // print text on top of the bar
   con->setDefaultForeground(TCODColor::white);
   con->printEx(x+width/2,y,TCOD_BKGND_NONE,TCOD_CENTER,
       "%s : %g/%g", name, value, maxValue);
}

The message log

We want a handy function to write to the log. Let's add it to Gui.hpp :

void message(const TCODColor &col, const char *text, ...);

This is our first variadic function. The three dots in the function prototype mean that there might be more parameters following the text parameter. If fact, we already used such a function when we sent messages to the standard output :

printf("%s attacks %s for %g hit points.", owner->name, target->name, damage);

We did not use the C++ cout stream because it's now very easy to move the logs to the game window. Simple replace the printf calls with :

engine.gui->message(TCODColor::white,
   "%s attacks %s for %g hit points.", owner->name, target->name, damage);

We want to be able to define the color of each line in the log. So we need a structure to store the message's text and its color. Since this structure is only used by the Gui class, we put it in its protected declaration zone.

protected :
   TCODConsole *con;
   struct Message {
       char *text;
       TCODColor col;
       Message(const char *text, const TCODColor &col);
       ~Message();
   };
   TCODList<Message *> log;

That's all for the log's declaration. Let's implement it. Log messages will be dynamically allocated so we need to clean them in the destructor :

Gui::~Gui() {
   delete con;
   log.clearAndDelete();
}

We also define a constant for the x position of the log messages (after the health bar) and how many log lines we can print (the size of the GUI console minus one for the mouse look line):

static const int MSG_X=BAR_WIDTH+2;
static const int MSG_HEIGHT=PANEL_HEIGHT-1;

In the render function, we draw the log just after the health bar :

// draw the message log
int y=1;
for (Message **it=log.begin(); it != log.end(); it++) {
   Message *message=*it;
   con->setDefaultForeground(message->col);
   con->print(MSG_X,y,message->text);
   y++;
}

We can add a slight improvement : darken the oldest lines to give a sense of fading. One way to darken a TCODColor is to multiply it with a float < 1.0.

// draw the message log
int y=1;
float colorCoef=0.4f;
for (Message **it=log.begin(); it != log.end(); it++) {
   Message *message=*it;
   con->setDefaultForeground(message->col * colorCoef);
   con->print(MSG_X,y,message->text);
   y++;
   if ( colorCoef < 1.0f ) {
       colorCoef+=0.3f;
   }
}

The oldest line will have 40% luminosity, the second oldest 70% and all other 100%.

Now we need to define the Gui::Message constructors and destructors :

Gui::Message::Message(const char *text, const TCODColor &col) :
   text(strdup(text)),col(col) {    
}

Gui::Message::~Message() {
   free(text);
} 

We're using the standard C strdup function to duplicate the text variable. Since this function allocates memory using the C function malloc, we need to release it with the C function free. If we want to use only C++, we would have to do :

Gui::Message::Message(const char *text, const TCODColor &col) :
   col(col) {
   this->text = new char[strlen(text)];
   strcpy(this->text,text);
}

Gui::Message::~Message() {
   delete [] text;
}

which is a bit more complex. Another way would be to store the text in a std::string object.

The core of the log is obviously the Gui::message function :

void Gui::message(const TCODColor &col, const char *text, ...) {
   // build the text
   va_list ap;
   char buf[128];
   va_start(ap,text);
   vsprintf(buf,text,ap);
   va_end(ap);

This is the black magic making it possible to handle those unknown parameters. It requires the stdarg.h standard header that defines the va_list type. va_start is used to initialize the va_list parameter list, using the name of the last named parameter. The parameters in the va_list variable can then be scanned using the va_arg function, but we don't need it because vsprintf will do it for us, formatting the message and writing it in the buf array (you remember that a C string is an array of char, don't you?). va_end is called to clean up the mess in the stack. You don't have to understand all the dirty tricks behind C variadic functions, but if you want to know more, check the man page.

Now a high quality log would do line wrapping for us, but to reduce this article length, we suppose that messages won't wrap : it's up to the Gui::message caller to ensure a line never reach the right border of the console. To avoid headache, we still offer some help : being able to put carriage returns '\n' to write several messages with a single Gui::message call.

For this, we need a pointer to the beginning and the end of each line :

char *lineBegin=buf;
char *lineEnd;

Before writing a new line in the log, we check that there is some room. If there isn't, we remove the oldest message :

do {
   // make room for the new message
   if ( log.size() == MSG_HEIGHT ) {
       Message *toRemove=log.get(0);
       log.remove(toRemove);
       delete toRemove;
   }

Now we're looking for a \n character in our line using the standard C strchr function :

// detect end of the line
lineEnd=strchr(lineBegin,'\n');

If such a character is found (lineEnd is not NULL), we replace it with '\0' or 0 which is the end-of-string character for C strings.

if ( lineEnd ) {
   *lineEnd='\0';
}

Now that the string is splitted, we can add the first part in the log :

// add a new message to the log
Message *msg=new Message(lineBegin, col);
log.push(msg);

And then we can loop, starting at the character just after the \n :

       // go to next line
       lineBegin=lineEnd+1;
   } while ( lineEnd );
}

The loop ends when (or if) no \n is found.

Now it's time to replace all the printf commands with engine.gui->message. You'll have to choose a color for each message. Here are all the calls updated :

Ai.cpp:

engine.gui->message(TCODColor::lightGrey,"There's a %s here",actor->name);

Attacker.cpp :

engine.gui->message(owner==engine.player ? TCODColor::red : TCODColor::lightGrey,
   "%s attacks %s for %g hit points.", owner->name, target->name,
   power - target->destructible->defense);
engine.gui->message(TCODColor::lightGrey,
   "%s attacks %s but it has no effect!", owner->name, target->name);           
engine.gui->message(TCODColor::lightGrey,
   "%s attacks %s in vain.",owner->name,target->name);

Destructible.cpp:

engine.gui->message(TCODColor::lightGrey,"%s is dead",owner->name);
engine.gui->message(TCODColor::red,"You died!");

I also added a welcome message in the Engine constructor :

gui->message(TCODColor::red,
 "Welcome stranger!\nPrepare to perish in the Tombs of the Ancient Kings.");

Note the \n character. The whole string wouldn't fit in a single line.

Mouse look

For this, we need to know the mouse's cursor coordinates. Let's add a TCOD_mouse_t field in the Engine :

TCOD_key_t lastKey;
TCOD_mouse_t mouse;
TCODList<Actor *> actors;

To get the mouse position, we update the checkForEvent call in Engine::update :

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

We want to print what's behind the mouse cursor, but only when it's in the player's field of view. The problem is that the Map::isInFov function does not handle out of the map values for its x,y parameters. Let's fix that :

bool Map::isInFov(int x, int y) const {
   if ( x < 0 || x >= width || y < 0 || y >= height ) {
       return false;
   }
   if ( map->isInFov(x,y) ) {

Now all we have to do is to add a protected renderMouseLook function in Gui.hpp:

void renderMouseLook();

And implement it. First let's ditch the case where the mouse cursor is not in FOV :

void Gui::renderMouseLook() {
   if (! engine.map->isInFov(engine.mouse.cx, engine.mouse.cy)) {
       // if mouse is out of fov, nothing to render
       return;
   }

We're going to simply write a comma separated list of everything on the cell. Once again, you can use a std::string object, which is easier (but less performant) to manipulate. Let's create a buffer for that string :

char buf[128]="";

This is equivalent to :

char buf[128]={'\0'};

or

char buf[128];
buf[0]=0;

Setting the first char of the array to 0 means it's an empty string.

Let's scan the actors list and add the name of every actor on the mouse cell in buf :

bool first=true;
for (Actor **it=engine.actors.begin(); it != engine.actors.end(); it++) {
   Actor *actor=*it;
   // find actors under the mouse cursor
   if (actor->x == engine.mouse.cx && actor->y == engine.mouse.cy ) {
       if (! first) {
           strcat(buf,", ");
       } else {
           first=false;
       }
       strcat(buf,actor->name);
   }
}

Finally, write this line in the first line of the GUI console.

   // display the list of actors under the mouse cursor
   con->setDefaultForeground(TCODColor::lightGrey);
   con->print(1,0,buf);
}

Don't forget to call renderMouseLook in Gui::render, just before blitting the console :

// mouse look
renderMouseLook();

That's it. You can compile as usual and enjoy your new shiny GUI.