Rogue C# – Leveling Up

At this point, our roguelike game is taking shape. We have a Game class to generate game maps, a Player class to handle the player information and a Game class to oversee them both. Rogue is a game that progresses through many levels, though, and there has to be an object rather than just walking around a map. In this chapter, we’ll see how to start adding more features onto the foundation that we’ve already put in place as we enable the player to pick up the gold we added in a previous chapter and progress through the map levels.

This post is part of a series on creating a roguelike game in C#. For the latest chapters and more information, please visit the Official Project Page on ComeauSoftware.com. The current code for this project is also available on Github.

Going for the Gold

The gold is on the map so now we need to add it to the player’s purse. The Player class already has a Gold property so it’s a matter of recognizing when the player has stepped onto a gold stash, finding out how much is there and adding it to that property. In a previous chapter, I mentioned that the gold stashes are just a character on the map and that the actual amount would be determined at random when it’s picked up.

From my playing of the game, the maximum gold in a stash is probably around 125 to 150 pieces but it tends toward the smaller amounts. For now, I’ll just do a random amount up to 125 but I want to make it possible later to make it dependent on the player’s stats in some way. The MapLevel class gets two new constants to define the minimum and maximum gold that can be picked up at once.

public const int MIN_GOLD_AMT = 10;
public const int MAX_GOLD_AMT = 125;

In later chapters, we’ll have a new class that holds a lot of the game’s reference information and some of these constants will probably be moved there but, for now, this works fine.

Then the Game class can get its own class-level random number generator; it’s certainly going to be using it a lot. It also gets a new class property to return the player’s current stats which can just be assembled from other properties.

private static Random rand = new Random();

public string StatsDisplay
{
    get { return cStatsDisplay} 
}

public string StatsDisplay
{
    get { return $"Level: {CurrentLevel}   Gold: {CurrentPlayer.Gold} "; }
}

Inventory is a big part of a roguelike game so the program should check to see if the player (or the monsters) have landed on something with each move. For this, we go back to the Game.MoveCharacter() method which is currently just moving the player and getting back the MapSpace object on which the player landed. This means it has all the information it needs to proceed.

if (player.Location.ItemCharacter == MapLevel.GOLD)
    PickUpGold();
private void PickUpGold()
{
    int goldAmt = rand.Next(MapLevel.MIN_GOLD_AMT, MapLevel.MAX_GOLD_AMT);
    CurrentPlayer.Gold += goldAmt;

    CurrentPlayer.Location.ItemCharacter = null;
    cStatus = $"You picked up {goldAmt} pieces of gold.";
}

The method updates the Gold property of the current player, removes the gold from the map and updates the status message shown at the top of the screen. Finally, back on the DungeonMain form, the KeyDown event can access the new StatsDisplay property to show a stats readout at the bottom of the form.

private void DungeonMain_KeyDown(object sender, KeyEventArgs e)
{
    if(currentGame!= null)
    {
        Debug.WriteLine(e.KeyValue);
        currentGame.KeyHandler(e.KeyValue, e.Shift);
        lblArray.Text = currentGame.CurrentMap.MapText();
        lblStatusMsg.Text = currentGame.StatusMessage;
        lblStats.Text = currentGame.StatsDisplay;
    }
}

With these few changes, our player has just become a gold prospector.

That Level 0 is a bit of a problem but we’ll fix that next.

Going to the Next Level

The Level 0 was an easy fix in the Game class’s constructor.

public Game(string PlayerName) {

    // Setup a new game with a map and a player.
    // Put the player on the map and set the opening status.

    this.CurrentLevel = 1;
    this.CurrentMap = new MapLevel();
    this.CurrentPlayer = new Player(PlayerName);
    this.CurrentPlayer.Location =      
      CurrentMap.PlaceMapCharacterLINQ(Player.CHARACTER, true);
    this.CurrentTurn = 0;
    cStatus = $"Welcome to the Dungeon, {CurrentPlayer.PlayerName} ...";         
}

Now we need a way for the player to go to the next level and for this, we’ll add another option to the Game.KeyHandler() method that the form calls when the user presses a key.

Some options in the classic Rogue game are case-sensitive; ‘Q’ quits the game while ‘q’ quaffs a potion. The period key tells the game that the player wants to rest for a turn while the ‘>’ uses a stairway if the player is standing on one at the time. The KeyVal argument that the KeyDown event passes is not case-sensitive, however. The ‘A’ key sends a code of 65 whether the SHIFT key is down or not so the KeyEventArgs class has the boolean Shift property which indicates if the key is pressed.

So the Game.KeyHandler() method now has two sections as shown below although it doesn’t have many options filled in yet.

(In DungeonMain_KeyDown)
currentGame.KeyHandler(e.KeyValue, e.Shift);


public void KeyHandler(int KeyVal, bool Shift)
{
    // Process whatever key is sent by the form.

    // Basics
    switch (KeyVal)
    {
        case KEY_WEST:
            MoveCharacter(CurrentPlayer, MapLevel.Direction.West);
            break;
        case KEY_NORTH:
            MoveCharacter(CurrentPlayer, MapLevel.Direction.North);
            break;
        case KEY_EAST:
            MoveCharacter(CurrentPlayer, MapLevel.Direction.East);
            break;
        case KEY_SOUTH:
            MoveCharacter(CurrentPlayer, MapLevel.Direction.South);
            break;
    }

    // Shift combinations
    if (Shift)
    {

    }
    else
    {

    }
}

The first section has a Switch statement for keys where the case doesn’t matter like the arrow keys. The second section uses an IF / ELSE on the Shift argument which will then have Switch statements. Let’s add a couple for the stairway commands. First the new constants for the key values and the maximum map level which we’ll use later:

private const int KEY_UPLEVEL = 188;
private const int KEY_DOWNLEVEL = 190;
private const int MAX_LEVEL = 26;
if (Shift)
{
    switch (KeyVal)
    {
        case KEY_DOWNLEVEL:
            if (CurrentPlayer.Location!.MapCharacter == MapLevel.STAIRWAY)
                ChangeLevel(1);
            else
                cStatus = "There's no stairway here.";
            break;
        case KEY_UPLEVEL:
            if (CurrentPlayer.Location!.MapCharacter == MapLevel.STAIRWAY)
                ChangeLevel(-1);
            else
                cStatus = "There's no stairway here.";
            break;
        default:
            break;
    }
}
else
{


}

Now, if the SHIFT key is down and the user presses either the > or < key, the program will check to see if they’re standing on a stairway and respond by calling a ChangeLevel() method we’ll write in a minute. Otherwise, it changes the status to alert the player that they can’t do that there.

Notice the exclamation point after the reference of the CurrentPlayer.Location property? That’s the null-forgiving operator. The player’s Location can be null if the player hasn’t been placed on the map yet and C# is very sensitive about referencing a possibly null value. It can’t always see when the value is unlikely to be null. This code isn’t going to fire unless the Game object has been created and the player’s location is defined as part of the Game class constructor so I know that this value won’t be null here. The null-forgiving character tells C# not to worry about it and a warning in the error list goes away. Still, when we talk about error handling in a later chapter, this will be a good place to put some code to catch the error if something goes wrong.

Changing Levels

The new ChangeLevel() method needs to make some decisions. Until the player has acquired the Amulet of Yendor on Level 26, the player can only go down the stairs so, if the player tries to go up instead, they need to be told no. The Amulet will be an inventory item and the method could probably check the inventory once we program it in but, for now, the Player class gets a new property. This will need to be set to false in the Player class constructor so the player doesn’t win the game as soon as they start.

public bool HasAmulet { get; set; }

Then the ChangeLevel() method does its work. It accepts one argument specifying how many levels to move the player.

private void ChangeLevel(int Change)
{
    bool allowPass = false;

    // If the player is trying to move up the stairs
    if (Change < 0)  
    {
        allowPass = CurrentPlayer.HasAmulet && CurrentLevel > 1;
        cStatus = allowPass ? "" : "You cannot go that way.";
    }
    // If the player is trying to move down the stairs
    else if (Change > 0) 
    {
        allowPass = CurrentLevel < MAX_LEVEL;
        cStatus = allowPass ? "" : "You have reached 
           the bottom level. You must go the other way.";            
    }

    if (allowPass)
    {
        CurrentMap = new MapLevel();
        CurrentLevel += Change;
        CurrentPlayer.Location = 
            CurrentMap.PlaceMapCharacter(Player.CHARACTER, true);
        cStatus = "";
    }
}

The logic in the first part is pretty straightforward – the player can only go upstairs if they have the Amulet and the current level is greater than Level 1. Later on, we’ll need to add code for the victory level that will let them go back to Level 0. If the player is on Level 26, they have to get the Amulet and go back upstairs.

Then, if they’re free to change levels, just update the CurrentLevel property, create a new map and put the player on it.

Unfortunately, our player is now stuck on Level 26 because we haven’t programmed in the Amulet.

The MapLevel class gets a new constant.

public const char AMULET = '♀';

Some part of the code needs to be responsible for actually putting the character on the map. The ChangeLevel() method might as well handle it, at least for now.

if (CurrentLevel == MAX_LEVEL && !CurrentPlayer.HasAmulet)
    CurrentMap.PlaceMapCharacter(MapLevel.AMULET, false);

Then we need a way to pick up the Amulet so we go back to the Game.MoveCharacter() method.

if (player.Location.ItemCharacter == MapLevel.GOLD)
    PickUpGold();
else if (player.Location!.ItemCharacter != null)
    cStatus = AddInventory();

This new Game.AddInventory() function will eventually decide if there’s room in the player’s inventory for something and then add it by reading properties from an inventory object. Some items can be grouped into one inventory slot so it will need to do that, too. For now, it can just do some simple updates for the Amulet.

private string AddInventory()
{
    // Inventory management. Currently just handling the Amulet.

    string retValue = "";

    if(CurrentPlayer.Location!.ItemCharacter == MapLevel.AMULET)
    {
        CurrentPlayer.HasAmulet = true;
        CurrentPlayer.Location!.ItemCharacter = null;
        retValue = "You found the Amulet of Yendor!  
            It has been added to your inventory.";
    }

    return retValue;
}

In the video below, you can see a demo of the game at this point. You’ll also see how you can break into the code by placing a breakpoint and change variables for a shortcut to Level 26.

Almost there!

This project is actually starting to look like a game, albeit without an ending. The only feature left from the original set of requirements is the fog of war which will make things more challenging when the player has to find their way around the map one hallway or room at a time.

This chapter has shown the addition of a few minor but important features. What I hope you’ll really notice is how easy it was in each case thanks to the organization provided by object oriented programming and some initial planning. Each time, it was a matter of determining what needed to happen, where in the program it should happen and how that part of the program should be further developed to make it happen.