Rogue C# – Speed it up!
Fast Play mode lets the player zip around the map without having to press the direction keys for every space but each space still equals one turn. In this chapter, we code for that and a couple other odds and ends. The game has enough commands that a help screen would be useful and we’ll add the player stats to the bottom of the screen to provide a little more information about the user’s progress in the game.
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.
Adding a Help Screen
In a recent chapter, I added the inventory screen so users could select items to use. Then I realized it was time for a help screen because of the number of key options that had been defined. The change followed the same basic pattern as the inventory screen.
First, there’s a new constant to define the ‘?’ key as the help key and it’s referenced in the Game.KeyHandler() method. The SHIFT key is held down for this character so it needs to be in the section of the method that addresses capitalized keys.
private const int KEY_HELP = 191;
case KEY_HELP: // Show help screen
GameMode = DisplayMode.Help;
ScreenDisplay = HelpScreen();
cStatus = "Here is a list of commands you can use.";
break;
The DisplayMode enumeration gets a new member as shown above and the code calls a new, simple function that returns the necessary screen text.
private string HelpScreen()
{
return "\n\nArrows - movement\n\n" +
"d - drop inventory\n\n" +
"e - eat\n\n" +
"s - search for hidden doorways\n\n" +
"i - show inventory\n\n" +
"> - go down a staircase\n\n" +
"< - go up a staircase(requires Amulet from level 26\n\n" +
"ESC - return to map.";
}
The RestoreMap() method also needs to reference the new enumeration member.
if (GameMode == DisplayMode.Inventory || GameMode == DisplayMode.Help)
{
GameMode = DisplayMode.Primary;
ScreenDisplay = DevMode ? CurrentMap.MapCheck() :
CurrentMap.MapText(CurrentPlayer.Location);
}
The Inventory and Help modes are really the only two from which the map itself should be restored when ESC is pressed. I also reinstated DevMode as separate variable. It’s not just a display mode, it’s an operational mode that may very well cause other changes in game operation later in the series. Also, if the game is in developer mode when the user asks for help, it needs to go back to the proper map display.
Player Stats
At this point, we can also complete the player stats display at the bottom of the screen. The original game displays the following:
- Game Level (done)
- Hit Points, max and current
- Strength, max and current
- Gold (done)
- Armor
- Experience, level and points
The Game.StatsDisplay() function is currently assembling this line and including the Turn number which I want to keep for now. Let’s start by adding a few properties to the Player class.
public int CurrentHP { get { return MaxHP - HPDamage; } }
public int CurrentStrength { get { return MaxStrength - StrengthMod; } }
public Inventory? Armor { get; set; }
The first two are just read-only properties that calculate the current hit points and strength based on other properties. The last is a nullable Armor property that will hold the armor inventory item the player is wearing.
The experience level in the last item is calculated off the number of experience points the player has accumulated. There are a couple of ways this has been calculated, depending on the version. I personally prefer the simpler, exponential method where:
- 10 points = Level 2
- 20 points = Level 3
- 40 points = Level 4
- 80 points = Level 5
- etc..
This can be calculated by a simple function in the Player class.
public int ExperienceLevel()
{
// Calculate player's experience level based on
// experience points.
int returnLevel = 0;
int nextLevelReq = 5;
do {
nextLevelReq *= 2;
returnLevel += 1;
}
while (this.Experience > nextLevelReq);
return returnLevel;
}
Then, we can update the Game.StatsDisplay() function.
public string StatsDisplay()
{
string retValue = "";
if (GameMode == DisplayMode.Primary)
{
// Assemble stats display for the bottom of the screen.
retValue = $"Level: {CurrentLevel} ";
retValue += $"HP: {CurrentPlayer.MaxHP} /
{CurrentPlayer.CurrentHP} ";
retValue += $"Strength: {CurrentPlayer.MaxStrength} /
{CurrentPlayer.CurrentStrength} ";
retValue += $"Gold: {CurrentPlayer.Gold} ";
retValue += $"Armor: {(CurrentPlayer.Armor != null ?
CurrentPlayer.Armor.ArmorClass : 0)} ";
retValue += $"Turn: {CurrentTurn} ";
retValue += $"Exp: {CurrentPlayer.ExperienceLevel()} /
{CurrentPlayer.Experience} ";
if (CurrentPlayer.HungerState < Player.HungerLevel.Satisfied)
retValue += $"{CurrentPlayer.HungerState} ";
}
else
retValue = "";
return retValue;
}
Of course, these stats will need to be updated elsewhere in the code but it’s starting to look like the real thing …
Fast Play
Fast Play mode is actually an important feature in the game. Without it, you’re hitting the arrow key for every space you want to move and that gets tedious after awhile. Once Fast Play is activated, you can zip between objects and everything goes a lot faster.
It also has some very specific rules and is a challenge to code. The character needs to stop for all doorways and items within rooms. When we introduce monsters into the game, the character needs to stop short of a monster and not strike it or trigger a fight, unless the monster itself is aggressive. In twisting hallways, the character needs to continue around corners but stop before entering a room. This is especially challenging because the hallways in this game are more complex than the classic game.
Let’s start with the new boolean property the Game class will need to signal Fast Play on and off and the constant for the key that will turn it on and off. The classic game used the Scroll Lock key but that’s really inconvenient now that many keyboards don’t even have one so we’ll use SHIFT-F.
private const int KEY_F = 70;
public bool FastPlay { get; set; }
In KeyHandler():
case KEY_F: // Fast Play
FastPlay = !FastPlay;
cStatus = FastPlay ? "Fast Play mode ON." : "Fast Play mode OFF";
break;
Now we can switch it on and off as needed; next, we actually need to do something with it. Everything regarding this feature happens with the movement keys so there will be a re-write of the Game.MoveCharacter() function but first, we need to look at the turn management code. Even though the player is moving continuously, each space still equals one turn so the game needs to complete each one. The code at the end of the KeyHandler() method that did this gets refactored into its own method because MoveCharacter() will now need to call it, too.
private void CompleteTurn()
{
do
{
// Perform whatever actions needed to complete turn
// (i.e. monster moves)
// Then, evaluate the player's current condition.
EvaluatePlayer();
// Increment current turn number
CurrentTurn++;
if (CurrentPlayer.Immobile > 0)
{
CurrentPlayer.Immobile =
CurrentPlayer.Immobile <= CurrentTurn ?
0 : CurrentPlayer.Immobile;
if (CurrentPlayer.Immobile == 0)
cStatus = cStatus + " You can move again.";
}
} while (CurrentPlayer.Immobile > CurrentTurn);
}
From KeyHandler():
if (startTurn) CompleteTurn();
Game.MoveCharacter() will also call the method to complete the turn as needed. Originally, this was a function that returned a boolean to the KeyHandler() method, signaling that a turn had started and needed to be completed, if the player was able to successfully move. That signal is no longer needed so the function doesn’t need to return anything; it can become a method instead. The code in the method can also be upgraded and simplified with some of the new functions we’ve added since it was written.
public void MoveCharacter(Player player, MapLevel.Direction direct)
The first thing the method now does is to call MapLevel.SearchAdjacent() to get the characters adjacent to the player’s location so it can evaluate the move.
Dictionary<MapLevel.Direction, MapSpace> adjacent =
CurrentMap.SearchAdjacent(player.Location!.X, player.Location.Y);
The rest of the method now happens inside a DO … WHILE loop. We need it to evaluate the first move and then check to see if it can perform each successive move in the same direction the player specified.
visibleCharacter = adjacent[direct].PriorityChar();
// The player can move if the visible character is within a room or a hallway and
// there's no monster there.
canMove = (MapLevel.SpacesAllowed.Contains(visibleCharacter) ||
adjacent[direct].ContainsItem()) &&
adjacent[direct].DisplayCharacter == null;
The new MapSpace.PriorityCharacter() function simplified the code here; the decision needs to be made based on what is visible on the map. We’ll need to modify this a little later when I introduce traps and Phantoms but it’s okay for now.
The MapLevel.DiscoverSurrounding() method was written to set all the player’s surrounding spaces to Discovered. Now it becomes a function that will also return True if one of those spaces returns something that the character needs to stop moving for.
// Discover the spaces surrounding the player and note if something is found.
foundItem = CurrentMap.DiscoverSurrounding
(player.Location.X, player.Location.Y);
In DiscoverSurrounding():
// If there's something one of the spaces, return True.
// Ignore player's space.
if (!retValue)
if (space.X != xPos || space.Y != yPos)
retValue = space.Occupied() ||
(!GlideSpaces.Contains(space.PriorityChar()));
The MapSpace.Occupied() function returns True if the space’s visible character is anything other than the MapCharacter or AltMapCharacter or equals the Player symbol, meaning that there’s something else in the space. The MapLevel.GlideSpaces List is a new public static List that actually contains all the character constants that FastPlay should ignore in the player’s surrounding spaces such as walls and empty spaces. So, if one of the spaces around the player is not in that list, the function returns True.
The foundItem variable is also set to True later if the current space contains gold or an inventory item so the player will stop when they pick something up.
After calling the new CompleteTurn() method, MoveCharacter() again calls SearchAdjacent() to update the list based on the player’s new location. Then the WHILE loop decides if it should continue.
while (!foundItem && CanAutoMove(player.Location, adjacent[direct]));
The CanAutoMove() function is a pretty simple function that applies many of the rules we’ve already established. The comments pretty well explain it.
private bool CanAutoMove(MapSpace Origin, MapSpace Target)
{
// Determine if the player can keep moving in the
// current direction. Target space must be eligible
// and the same map character as the current space.
// If the player is in a hallway, they must stop at any junctions.
return FastPlay
& Target.DisplayCharacter == null // No monster
& !Target.ContainsItem()
& Target.MapCharacter == Origin.MapCharacter
& MapLevel.SpacesAllowed.Contains(Target.PriorityChar())
& CurrentMap.SearchAdjacent(MapLevel.HALLWAY,
Origin.X, Origin.Y).Count < 3;
}
Note the use of the & operator rather than the short-circuiting && operator. With this many tests, I want all of them evaluated.
There is one feature of Fast Play that I haven’t included here; the ability to go around corners which I won’t bother with because of the complexity of the hallways in this version. It just wouldn’t make as much difference. Another possible enhancement is a slight delay in the movement; the player zips around pretty much instantly now and it might be nice to actually see them moving along a hallway.
A few other recent changes …
A clear naming strategy is important in code, especially in a larger project and that’s another reason why advance planning is important. I changed the InventoryType enumeration name to InvCategory because the enumeration members represent categories rather than actual items.
The MapText() function previously searched for the player’s location but this was unnecessary since the Game class already has the information so I changed the function to accept the MapSpace object as a parameter.
Not that it mattered too much but I decided the MAX_FOODVALUE and MIN_FOODVALUE constants belonged in the Inventory class instead of the Player class as they are inventory properties.
Each inventory item has its own display character and the MapSpace.ItemCharacter property should not have to be explicitly set to match it each time as I was starting to do. I updated the MapText() and MapCheck() functions to add MapInventory.DisplayCharacter to the character display priority list so if the MapSpace contains inventory, the DisplayCharacter will be shown. I also refactored the decision of which character to show into its own MapLevel.PriorityChar() function.
Finally, I found an actual bug that needed to be fixed – the map was getting really stingy on gold. I raised the ROOM_GOLD_PCT constant and it got worse. Then I noticed that the call to the Random class was using the wrong comparison operator.
if(rand.Next(1, 101) > ROOM_GOLD_PCT)
It was only adding gold to the room if the random value was greater than the probability constant rather than under it – easy to miss, easy to fix.
if(rand.Next(1, 101) < ROOM_GOLD_PCT)
As a final note, one of the nice features in C# is XML commenting which enables you to not only document your code but also make descriptions available in Intellisense when you’re referencing the functions and classes you’ve created.
If you position your cursor over the code you want to document and type three backslashes, Visual Studio will automatically insert a template that allows space for a description of the code itself and any parameters.
Then, when you’re referencing the code like the function you see above, the Intellisense will show the descriptions you’ve provided.
These descriptions can also be used to document class properties and they can be collapsed by clicking on the little minus sign next to them. The collapsed comments will still show the basic description.
Sign up for our newsletter to receive updates about new projects, including the upcoming book "Self-Guided SQL"!
We respect your privacy and will never share your information with third-parties. See our privacy policy for more information.
0