Rogue C# – The Monsters Strike Back
The last chapter ended with a major refactoring of the program after I realized it was better to keep the monster and inventory collections at class level rather than on the map array itself. This is another example of something that probably could have been seen with more advance planning and done from the start but tracking down minor bugs and knocking them out one by one is also fun sometimes.
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.
Getting things in order
The cleanup continued as I reviewed all the classes for any code that was replaceable or not used. Visual Studio has some excellent features that enable you to do this pretty quickly such as the Find All References selection on the context menu and the references count above each function and method.
- The MapSpace class had a couple of functions that I’d written to provide a quick view of whether something was lurking in the space but then barely used. I replaced both of these with the new MapLevel.DetectInventory() and DetectMonster() functions.
- The MapInventory property in the MapSpace class also went away as everything is kept on the class-level list.
- The public access to the MapLevel array is gone. This array probably shouldn’t be searched directly from outside the class. The functions in the MapLevel class should provide all information.
- The Player.SearchInventory() function now returns a nullable Inventory object instead of a boolean and is overloaded to accept either an item name or the item category, in which case it will return the first matching item. I used this to let the player actually wear the armor that was being provided at the start of the game.
// Have player wear the armor.
this.Armor = SearchInventory(Inventory.InvCategory.Armor);
There’s more cleanup to do. The MapSpace class doesn’t need the ItemCharacter and DisplayCharacter properties anymore since the MapText() / MapCheck() functions can determine what character to display based on the contents of the inventory and monster lists. This eliminates some code and extra steps on each rendering of the map.
My first step was to promote the PriorityChar() function from the MapSpace class to the MapLevel class, have it accept a specific space to evaluate and then change what it looks at.
public char PriorityChar(MapSpace Space)
{
Monster? monst = DetectMonster(Space);
Inventory? inv = DetectInventory(Space);
char retValue;
if (monster != null)
retValue = monster.DisplayCharacter;
else if (inv != null)
retValue = inv.DisplayCharacter;
else if (Space.AltMapCharacter != null)
retValue = (char)Space.AltMapCharacter;
else
retValue = (char)Space.MapCharacter;
return retValue;
}
The original function was referenced in six different places so those all had to be changed over to the new function which was no big deal.
It seems to work just fine although there is a bug that sometimes occurs when the player tries to attack a monster. The player character disappears or switches places with the monster. I suspect it has to do with the continued references to the MapSpace.DisplayCharacter property which might be outdated briefly after the ActiveMonsters list is updated and before the screen updates.
Getting rid of the remaining references might fix it. The ItemCharacter and DisplayCharacter properties of the MapSpace class have 8 and 9 references left, respectively. They can usually be replaced with calls to DetectMonster() and DetectInventory().
There were also a couple of adjustments needed because the DisplayCharacter held both monsters and the player character which now has to be placed on the map separately.
public char PriorityChar(MapSpace Space, bool ShowHidden)
{
Monster? monster = DetectMonster(Space);
Inventory? invItem = DetectInventory(Space);
char retValue;
if (monster != null)
retValue = monster.DisplayCharacter;
else if (Space == CurrentPlayer.Location)
retValue = Player.CHARACTER;
else if (invItem != null)
retValue = invItem.DisplayCharacter;
else if (Space.AltMapCharacter != null && !ShowHidden)
retValue = (char)Space.AltMapCharacter;
else
retValue = (char)Space.MapCharacter;
return retValue;
}
This was also a chance to eliminate some duplication between MapLevel.MapText() and MapLevel.MapCheck(). MapText() is the function that normally determines what character to output to the map. MapCheck() is used when DeveloperMode is ON and everything needs to be visible. MapCheck() was making its own decision on the priority character and not factoring in the MapSpace.AltMapCharacter property which is used to substitute characters for things that need to be searched for, like doorways.
In the code shown above, the PriorityChar() function now accepts a boolean parameter that it will use to determine if the AltMapCharacter should be left out of the priority choice. In most cases, it will be false but MapCheck() will set it to True to reveal everything.
public string MapCheck()
{
StringBuilder sbReturn = new StringBuilder();
// Iterate through the two-dimensional array and use StringBuilder to
// concatenate the proper characters into rows and columns for display.
for (int y = 0; y <= MAP_HT; y++)
{
for (int x = 0; x <= MAP_WD; x++)
sbReturn.Append(PriorityChar(levelMap[x, y], true));
sbReturn.Append("\n"); // Start new line.
}
return sbReturn.ToString();
}
Finally, the MapLevel.RefreshMapLocations() method that I was so pleased with in the last chapter is already obsolete because the player, monsters and inventory no longer need to be written to the array. This removes the last references to the ItemCharacter and DisplayCharacter properties in the MapSpace class so those can be removed. MapSpace is reduced to seven properties which should reduce the memory footprint slightly, not that it was very large before.
Updating the Player Stats
Each time the player presses a key, the stats display at the bottom of the screen is updated by the Game.StatsDisplay() function which already looks at all the right properties.
public string StatsDisplay()
{
string retValue = "";
if (GameMode == DisplayMode.Primary)
{
// Assemble stats display for the bottom of the screen.
retValue = $"Level: {CurrentLevel} ";
retValue += $"HP: {CurrentPlayer.CurrentHP}/
{CurrentPlayer.MaxHP} ";
retValue += $"Strength: {CurrentPlayer.CurrentStrength}/
{CurrentPlayer.MaxStrength} ";
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;
}
The game Level, Gold, Armor and Turn stats are already being updated. Nothing is affecting HP or Strength yet but we do need a way to regenerate HP as the player moves. I personally think Strength should regenerate, too, so I’ll write that in there. The player mainly gains experience from fights and then the XP level is calculated based on the number of points accumulated.
Experience Level
First, I found a design issue with calculating the experience level. When a player’s XP level increases, their maximum hit points are supposed to rise and this means that we need a way to mark when this increase actually happens but, right now, we can’t because the XP level is purely a calculation based on whatever the experience points happen to be at the time. There needs to be a way to mark the actual point of change.
So, the Player.ExperienceLevel() function goes away and is replaced by two new properties in the Player class.
public int ExpLevel { get; set; } = 1;
public int NextExpLevelUp { get; set; } = 10;
Just like with Hunger and other player state properties, the NextExpLevelUp property will maintain a value indicating the point at which the XP level should change. These have default values so the constructors don’t have to bother with them.
The Game class gets a new constant to indicate the maximum the HP limit should be raised. Then, at the end of the Game.EvaluatePlayer() method, the goal is doubled if the players experience exceeds the next goal. The experience level is also increased and their max HP is raised by a random number within the HP_LEVEL_INCREASE limit.
private const int HP_LEVEL_INCREASE = 10;
// Check for experience level increase.
if(CurrentPlayer.Experience >= CurrentPlayer.NextExpLevelUp)
{
CurrentPlayer.NextExpLevelUp *= 2;
CurrentPlayer.ExpLevel += 1;
CurrentPlayer.MaxHP += rand.Next(1, HP_LEVEL_INCREASE + 1);
if (CurrentPlayer.HPDamage > 0)
CurrentPlayer.HPDamage -= rand.Next(1, CurrentPlayer.HPDamage);
UpdateStatus($"Welcome to Level {CurrentPlayer.ExpLevel}.", false);
}
I also remembered that there are different amounts of experience rewarded for defeating each monster so the Monster class needs a new property which will need to be included in the monster object templates.
public int ExpReward { get; set; }
In the original game, most monsters awarded 350 points or below, plus 1/6 the monster’s hit points, but the Dragon gave 6800 and the Phantom, 4000.
In the Game.Attack() method, we can add some code to award the experience points to the player.
if(Defender.CurrentHP < 1)
{
CurrentMap.ActiveMonsters.Remove(Defender);
UpdateStatus($"You defeated the {Defender.MonsterName.ToLower()}.", false);
CurrentPlayer.Experience += Defender.ExpReward + (int)(Defender.MaxHP / 6);
}
Hit Points
The regeneration of hit points is based on a number of turns which is determined by the player’s experience level. In the early versions of the game, it started with 1 HP / 18 turns at XP Level 1, went down to 1 HP / 3 turns by level 8 and then gave a random number of points with an increasing limit on each XP level for every 3 turns after that.
I could program a function that recreates the original system but it seemed a little arbitrary anyway. For now, I want a simple formula that will regenerate the points faster based on level. To average things out a little from the original game, I’ll regenerate a random number of points based on the player’s current experience level every 12 turns. To code this, I’ll start with a new constant in the Game class to hold the number of turns. This can be increased or decreased if necessary.
private const int HEAL_RATE = 12;
The Game.EvaluatePlayer() method is called on each turn after the player and monsters have completed their actions. Right now, it’s just managing hunger and ending the game if the player doesn’t eat often enough. At the end of this method, assuming the game is still going, I’ll let it decide on regenerating the HP.
// Regenerate hit points.
if (GameMode != DisplayMode.GameOver && CurrentTurn % HEAL_RATE == 0 &&
CurrentPlayer.HPDamage > 0)
{
CurrentPlayer.HPDamage -=
rand.Next(1, CurrentPlayer.ExpLevel + 1);
}
if (CurrentPlayer.HPDamage < 0) CurrentPlayer.HPDamage = 0;
If the game is not over and the current turn is divisible by the HEAL_RATE constant and the player has some damage, give them back a random number of points up to half of their current experience level.
I’d like to start testing this now, so, I’ll overload Game.Attack() to allow the monsters to fight back.
private void Attack(Monster Attacker, Player Defender)
{
// Basic attack method. In progress.
// Start with a 50% chance of landing a punch.
bool hitSuccess = rand.Next(1, 101) > 50;
int damage = 0;
// Random HP between monster's min and max attack damage.
if (hitSuccess)
{
UpdateStatus($"The {Attacker.MonsterName.ToLower()} hit you.", false);
damage = rand.Next(Attacker.MinAttackDmg, Attacker.MaxAttackDmg + 1);
}
else UpdateStatus($"The {Attacker.MonsterName.ToLower()} missed you.", false);
Defender.HPDamage += damage;
// If the player has been defeated, end the game.
if (Defender.CurrentHP < 1)
{
GameMode = DisplayMode.GameOver;
CauseOfDeath = Attacker.MonsterName + " attack.";
}
}
Now that there’s more than one way for the player to die, there needs to be some way of communicating this to the RIP screen at the end so I’m adding a new property to the Game class.
public string? CauseOfDeath { get; set; }
Then the Game.RIPScreen() function can insert this into the graphic for the headstone. The EvaluatePlayer() method is also updated to set the CauseOfDeath property as “starvation” if the player dies that way.
Before the player gets stomped to death, there’s usually some kind of fight and the monster needs some way to know it’s time to attack. In the player’s Attack() method overload, a new line is added right after the player throws a punch, whether the punch lands or not. This references the CurrentState enumeration from the Monster class.
// Either way, if the monster wasn't angry before, it sure is now.
Defender.CurrentState = Monster.Activity.Angered;
Then, in Game.MoveMonster() …
timeToMove = (monster.CurrentState == Monster.Activity.Wandering ||
monster.CurrentState == Monster.Activity.Angered ||
rand.Next(1, 101) >= monster.Inertia);
Later in the method, the monster has chosen a direction that would put it right on top of the player. This starts an attack.
if (canMove)
monster.Location = adjacent[direct];
else
{
if (CurrentMap.DetectMonster(adjacent[direct]) != null && monster.Aggressive)
// The monster just tried to run into another monster. For now, just change
// direction.
monster.Direction = rand.Next(1, 101) > 50 ? direct270 : direct90;
else if (adjacent[direct] == CurrentPlayer.Location
&& monster.CurrentState == Monster.Activity.Angered)
// Attack the player
Attack(monster, CurrentPlayer);
This means that, for now, the monsters will only attack if provoked. This might need to change later, at least depending on the monster. I’m also noticing that some of them keep running away and I’m still getting that glitch where the player disappears sometimes so I need to review the code.
Strength
The original game only regenerated strength if the player used scrolls of Restore Strength or Increase Strength. That doesn’t make much sense to me and it leaves the player vulnerable if the game’s random selection gets stingy on these scrolls. For this game, I’m also rewarding a strength point back when the player eats. This can be done in the Game.Eat() function.
// Reward a strength point if needed.
if (CurrentPlayer.StrengthMod > 0) CurrentPlayer.StrengthMod--;
Display Issues
I noticed that the game was having problems on another display so I reduced the font size of the label control displaying the map and set a minimum size for the main form to 1160, 837. Visual Studio also had an annoying habit of losing the event definitions if the form was resized on my other machine. That took some time to track down.
The big display change, however, is in the Status display. I didn’t like having just most recent status showing at the top of the screen and it’s just not as necessary as it might have been with the original game. I replaced the status label at the top with a List control, replaced the Game.cstatus property with a Binding list and then replaced all the status updates in the Game class with references to a central method that would update the BindingList.
public BindingList<string> StatusList = new BindingList<string>();
private void UpdateStatus(string Status, bool Confirm)
{
if (Confirm)
{
StatusList.Insert(0, Status);
MessageBox.Show(Status);
}
else StatusList.Insert(0, Status);
}
There are certain updates that I want the player’s complete attention for, such as when they’re fainting. For those, the Confirm parameter can be set to True and the status update will also be displayed as a message box.
The BindingList serves as a data source for the List control on the form and is attached to it when the user clicks Start.
listStatus.DataSource = currentGame.StatusList;
I decided to keep the most recent message at the top so the KeyDown event on the form now includes these lines:
listStatus.SelectedIndex = 0;
listStatus.SelectedIndex = -1;
What’s next?
The game actually has some challenge to it – enough that I haven’t made it through a playthrough since the monsters started fighting back. There’s still a lot to be done, though.
- Inventory is the big item. Food, arrows and studded leather armor only go so far. We also need more key commands to actually use some of this stuff.
- Fight mechanics need to be completed. Monster and player stats need to be taken into account to make these fights a bit more real. Monster special attacks also need to be programmed.
- Smaller items include the ability to start another game after the player dies, a title screen and a scoreboard.
- There are some nagging bugs that still need to be worked out and maybe some additions onto Developer Mode to assist with testing.
That doesn’t even include things like being able to save the games. We still have a long way to go with this one but it’s looking good!
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