RPG System #2: Adding an attribute system

Tested On

You've created a basic levelling system akin to RPGs, and now we're going to expand it. When the player levels up, they will be able to choose how their character gets stronger, by being able to add points to specific attributes.

By following this guide, not only will you have a working attribute system, but you will:

  • have learned how to use CenterWindow() to create text dialogue boxes in Wolf3D.
  • have created a basic prompt that requires the player to input a choice
  • created and called your own functions within the Wolf3D engine

For this expansion of our Basic Levelling System we're going to include stats that affect damage, health and speed (Effectively Strength, Health and Dexterity).

To track these elements, we'll want to add individual variables to track each in the gamestate structure of WL_DEF.

//---------------
//
// gamestate structure
//
//---------------

typedef struct
{
    short       difficulty;
    short       mapon;
    int32_t     oldscore,score,nextextra;
    short       lives;
    short       health;
    short       ammo;
    short       keys;
    int16_t     bestweapon,weapon,chosenweapon;
    
    byte        lvl, oldlvl;        // These are the attributes we added in our Basic Levelling System guide
    byte        str,end,spd;        // str = Strength, end = Endurance, spd = Speed

Being kept in the gamestate structure, the information about these variables will be reset on a new game, and kept when the user saves - perfect for our needs!

The first thing we'll do is set up our attributes to affect their respective elements. We want:

  • Strength to affect the amount of damage the player deals to enemies.
  • Endurance to increase the player's maximum health.
  • Speed to affect how fast the player can run.

Each of these will be easy to implement on a basic level. First, we'll look at strength. Damage is calculated in both the GunAttack() and KnifeAttack() functions in WL_AGENT. But rather than have to change both, we can see in these functions that they BOTH send their damage calculations to the DamageActor() function in WL_STATE.
So, rather than need to make two changes, we'll make our alteration in that mutual function:

/*
===================
=
= DamageActor
=
= Called when the player succesfully hits an enemy.
=
= Does damage points to enemy ob, either putting it into a stun frame or
= killing it.
=
===================
*/

void DamageActor (objtype *ob, unsigned damage)
{
    madenoise = true;

    damage += gamestate.str*2;

    //
    // do double damage if shooting a non attack mode actor
    //
    if ( !(ob->flags & FL_ATTACKMODE) )
        damage <<= 1;

    ob->hitpoints -= (short)damage;

    if (ob->hitpoints<=0)
        KillActor (ob);

Now, when either GunAttack() or KnifeAttack() send their damage calculations, DamageActor() will apply our strength-based modifier before deducting from the enemies hitpoints.

Next is having our new endurance stat (gamestate.end) affect maximum health. In our guide to a Basic Levelling System, we actually implemented this, though in that version we used the player's level variable (gamestate.lvl) to calculate this.

For example, our code for the HealSelf() function currently reads:

/*
===============
=
= HealSelf
=
===============
*/

void    HealSelf (int points)
{
    gamestate.health += points;
    if (gamestate.health > 100 + (gamestate.lvl*10))
        gamestate.health = 100 + (gamestate.lvl*10);

    DrawHealth ();
    gotgatgun = 0;    // JR
    DrawFace ();
}

We'll want to change that to look at our new endurance variable instead. We'll still keep it increasing in amounts of 10, assuming a level cap of 10 so health levels don't get too crazy.

/*
===============
=
= HealSelf
=
===============
*/

void    HealSelf (int points)
{
    gamestate.health += points;
    if (gamestate.health > 100 + (gamestate.end*10))
        gamestate.health = 100 + (gamestate.end*10);

    DrawHealth ();
    gotgatgun = 0;    // JR
    DrawFace ();
}

Just like when initially creating this effect in the Basic Levelling System guide (This step), you'll want to look at the code for the collectible items in the GetBonus() function in WL_AGENT and change any times the game calculates maximum health to take into account gamestate.end.
If you don't do this, you won't be able to pick up health objects if you have more than 100 health, even if your maximum health is higher!

Finally, we want our player's speed to change based on the gamestate.spd variable. This is another simple change. Player speed for walking and running are defined by their respective BASEMOVE and RUNMOVE values, as defined in WL_DEF:

/*
=============================================================================

                            WL_PLAY DEFINITIONS

=============================================================================
*/

#define BASEMOVE    35
#define RUNMOVE     70

These are the default values for walking and running. However, we'll want to alter them at the time of use, which will take us to WL_PLAY, in the PollKeyboardMove() function:

/*
===================
=
= PollKeyboardMove
=
===================
*/

void PollKeyboardMove (void)
{
    int delta = buttonstate[bt_run] ? RUNMOVE * tics : BASEMOVE * tics;

    if (Keyboard[dirscan[di_north]])
        controly -= delta + (gamestate.spd*4);
    if (Keyboard[dirscan[di_south]])
        controly += delta + (gamestate.spd*4);
    if (Keyboard[dirscan[di_west]])
        controlx -= delta + gamestate.spd;
    if (Keyboard[dirscan[di_east]])
        controlx += delta + gamestate.spd;
}

In this function, the game checks if the run button (traditionally shift) is being held, and decides whether to use the RUNMOVE or BASEMOVE to calculate the value of delta (which effectively can be considered our directional speed). Then depending on the direction key pressed (Forward, Back, Left and Right), the game will move the player in that direction according to delta.

We've altered the function to add our speed variable onto delta at the time of movement. In the case of moving forward and backward, we are multiplying the value by 4, so the player can sooner feel the changes to speed that they have made. For left and right we are keeping it to just adding the basic value of our speed variable, as you don't necessarily want to spin around too fast.
Feel free to tinker with the calculations for gamestate.spd to find something that feels right. Maybe you want turning to be faster, or not move quite so fast. Make sure to keep your LEVELCAP in mind - at levels such as 50, the player could potentially reach speed bonuses of 200, which could be way too fast to actually be playable.

We have implemented the basics needed for our strength, endurance and speed variables to affect the game.

For our game, we want the player to be able to increase these stats as a reward for levelling up. We'll have a dialogue box appear with the prompt for the player to pick one stat to increase.

This part of the guide assumes you have built the Basic Levelling System guide, and have the dialogue box generated by the LevelUp() function in WL_AGENT:

void LevelUp (void)
{
     CenterWindow (10,3);
     US_CPrint ("Leveled up!");
     VW_UpdateScreen();
     IN_Ack ();
}

This is the window that appears when the player achieves a new character level. Right now, it simply displays text informing the player that they have levelled up, but we want to display more information and even get specific key presses from the player.

First, we'll add more text to inform the player of what comes with levelling up:

void LevelUp (void)
{
     CenterWindow (20,8);
     fontnumber = 1;
     US_CPrint ("Leveled up!");
     fontnumber = 0;
     US_CPrint ("Increase a stat +1:");
     US_Print ("\n");
     US_Print ("1.STR - Damage \n");
     US_Print ("2.END - Maximum HP \n");
     US_Print ("3.SPD - Speed \n");
     
     VW_UpdateScreen();
     IN_Ack ();
}

We've increased the size of the window, and added additional text. This text tells the player to choose a stat out of the selection shown.

We've done a number of new things here. Firstly, we're referencing the fontnumber variable. In Wolf3D there are two default font choices - a small font (0) and a large font (1). We can see these in use throughout the game. Here, we're calling fontnumber = 1 to tell the game to use the larger font when printing the "Leveled Up!" text, then calling fontnumber = 0 to switch the rest of the text back to the small font.

Along with using US_CPrint, we're also calling US_Print. The fundamental difference between the two is that US_CPrint() will centre it's text horizontally, while US_Print() will not.
Additionally, US_Print() does not automatically start a new line each time you use it in the function. Instead, we add \n to our text to tell the game to start a new line. That does mean you could combing our uses of US_Print() together into one line:

US_Print ("\n1.STR - Damage \n2.END - Maximum HP \n3.SPD - Speed \n");

And achieve basically the same effect in game, but we're going to keep them separated for the sake of readability.

At this stage, this is what it looks like when our player levels up now:

Level Up!

Fantastic! Right now our window still closes the moment the player presses any button. We want the game to remain paused until the player makes a valid selection (Pressing either 1, 2 or 3 on the keyboard).

To do that, we'll wrap our dialogue window in a while loop, that will stay active until either it's statement is no longer correct (As in, our new boolean selected is no longer set to false).

void LevelUp (void)
{
    boolean selected = false;
     
    while (!selected)
    {
        CenterWindow (20,8);
        fontnumber = 1;
        US_CPrint ("Leveled up!");
        fontnumber = 0;
        US_CPrint ("Increase a stat +1:");
        US_Print ("\n");
        US_Print ("1.STR - Damage \n");
        US_Print ("2.END - Maximum HP \n");
        US_Print ("3.SPD - Speed \n");
        
        VW_UpdateScreen();
        IN_Ack();
        
        if (Keyboard[sc_1])
        {
            gamestate.str++;
            selected = true;
            IN_ClearKeysDown();
        }
        else if (Keyboard[sc_2])
        {
            gamestate.end++;
            selected = true;
            IN_ClearKeysDown();
        }
        else if (Keyboard[sc_3])
        {
            gamestate.spd++;
            selected = true;
            IN_ClearKeysDown();
        }
    }
}

Inside of the loop, most of it is largely the same as before, but we also add a collection of if statements looking at what button the player is pressing. If they press 1, 2, or 3 on their keyboard (Corresponding with the numbers printed in the dialogue window), we increase the corresponding stat variable, change selected to be true and clear the key presses (This is so we don't accidentally make the player change weapon!)

Because selected is switched to true when one of these keys is pressed, the dialogue window will close. However if any other key is pressed, the window will stay and the game will remain paused (as selected will still be set to false).

Note: If you are using DDWolf, the source port uses a boolean for key presses. As such, when you call Keyboard[x], you'll need to call Keyboard(x) instead.

The last thing we'll want to do is allow the player to check what their current stats are.

To do this we'll be creating one more dialogue window. This one will be accessible to the Player at all times, by holding down the Capslock key.

We'll write our function, CheckPlayerStats(), at the bottom of WL_DRAW:

void CheckPlayerStats (void)
{
    char tempstr[2];
    
    CenterWindow(10,10);
    fontnumber = 1;
    US_CPrint("Stats");
    fontnumber = 0;

    // Display the player's current level
    US_Print("Level: ");
    itoa (gamestate.lvl+1, tempstr, 10);
    US_Print(tempstr);

    fontnumber = 1; //switching back to large font

    // Display Strength
    US_Print("\n\nSTR - ");
    itoa (gamestate.str, tempstr, 10);
    US_Print(tempstr);

    // Display Endurance
    US_Print("\nEND - ");
    itoa (gamestate.end, tempstr, 10);
    US_Print(tempstr);

    // Display Speed
    US_Print("\nSPD - ");
    itoa (gamestate.spd, tempstr, 10);
    US_Print(tempstr);
}

Here we have a simple dialogue box generated with CenterWindow(), but when calling the text to display we're doing something different.

You can see several instances of US_CPrint() and US_Print() that function much like what we have worked with earlier in the guide, but we are also calling something called itoa().

itoa() is a standard function of C++ programming, and allows us to convert an integer variable into a string of text. It takes 3 variables, in the following format:

itoa (variable, character variable, radix);

First it takes the variable to be converted - which in the case of the above code will be the variable for the player's level, and each attribute. Then, the character variable that the converted string will be saved into. Finally, the value of radix determines how the value of the first variable is converted. By specifying a radix of 10, we are ensuring the result is the decimal (regular numerical) value of our variable.

We are using the tempstr character variable we have created at the top of this function to store and display each value in turn. It can currently store two characters, but if you're going to have displays that go into the hundreds you may want to change this to 3.

Now, we need to call our new function when the game detects the player presses the Capslock key. For that we scroll up a little in WL_DRAW to find the ThreeDRefresh() function. This is a critical function for the game and should be edited with care.

Inside of the function we can find the following code (This might differ depending on source port):

//
// draw all the scaled images
//
    DrawScaleds();                  // draw scaled stuff

#if defined(USE_FEATUREFLAGS) && defined(USE_RAIN)
    if(GetFeatureFlags() & FF_RAIN)
        DrawRain();
#endif
#if defined(USE_FEATUREFLAGS) && defined(USE_SNOW)
    if(GetFeatureFlags() & FF_SNOW)
        DrawSnow();
#endif

    DrawPlayerWeapon ();    // draw player's hands

    if(Keyboard[sc_Tab] && viewsize == 21 && gamestate.weapon != -1)
        ShowActStatus();

We can see as an example of code functioning similarly to what we want to accomplish - if the player is using the fullscreen display that hides the statusbar, the game will check if the player is holding down the TAB key, and if they are it will display the statusbar for them.

We'll add our lines directly underneath:

    DrawPlayerWeapon ();    // draw player's hands

    if(Keyboard[sc_Tab] && viewsize == 21 && gamestate.weapon != -1)
        ShowActStatus();
        
    if (Keyboard[sc_CapsLock])
       CheckPlayerStats();

The game now checks for if the player has hit the Capslock key, and if they have it will attempt to run CheckPlayerStats(). Reminder: In DDWolf, you'll need to write the if condition as Keyboard(sc_CapsLock).

You'll find if you try to compile now, you will get an error from this use of CheckPlayerStats(). That is because we defined our function after ThreeDRefresh(). We need to define CheckPlayerStats() earlier. We could move the entire function up, but instead, we'll just add a declaration that the variable exists at the top of WL_DRAW:

//
// refresh variables
//
fixed   viewx,viewy;                    // the focal point
short   viewangle;
fixed   viewsin,viewcos;

void    TransformActor (objtype *ob);
void    BuildTables (void);
void    ClearScreen (void);
int     CalcRotate (objtype *ob);
void    DrawScaleds (void);
void    CheckPlayerStats (void);

Now that we have declared the existence of CheckPlayerStats() before ThreeDRefresh(), your code should now compile, and pressing the Capslock key should display your current stats:

Player Stats

 

If everything is working, then congratulations! You've successfully implemented a simple attribute system to allow the player to customize how they grow when levelling.

Some further notes:

  • If the player levels up when finishing a map, the LevelUp() window will still appear, and cause potential graphical issues. What is a possible solution? Could you disable the prompt until the next level loads? Or effectively refresh the Intermission screen? Maybe even set up the system so the player can choose when they want to add the points.
  • CenterWindow() is a function with a lot of potential use in games - maybe you want to display hints to the player, or create a dialogue box for an NPC!
  • What about creating different stat attributes for the player? There's a lot of things you could change - maybe the player could gain the ability to collect more ammo from pickups, or fire their weapon faster!
  • Try messing with the displays - different sizes, text, and maybe even have a look at the SETFONTCOLOR() function and see if you can successfully change the colour of the title of the CheckPlayerStats() window!
  • Remember, balance can be important! If your player's attributes don't affect the game enough, or affect it too much, that could be a problem for the player's enjoyment! Make sure to test your game, and see how things feel.