Rogue C# – The Monster Shuffle
We put monsters on the map in the last chapter and now it’s time to get them moving around. Earlier in the series, we solved the challenge of constructing hallways by programming the game to perceive the map and create paths between rooms. This latest task of getting monsters to move around the map on their own is a little similar. We have the available paths laid out but now the monsters have to make decisions about their direction and destination. Some monsters might be expected to have differing opinions about these matters. Before we start coding, let’s lay out some requirements.
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.
The Algorithm
For simplicity’s sake, we’ll start off with the monsters moving according to the same set of decisions although in different directions.
A monster is spawned in one of the rooms and, on each turn, has to make a series of decisions. The first is whether to move at all; so long as it’s not provoked, any monster can decide to stay right where it is on any given turn or for its entire life. In fact, the Angered property that I added to the class probably needs to be replaced to allow for different activity levels.
public enum Activity {
Resting = 0,
Wandering = 1,
Angered = 2
}
public Activity CurrentState { get; set; } = Activity.Resting;
public int Inertia {get; set; }
The Inertia property can be used to determine how likely the monster is to go into a rest period or start wandering again.
Once a monster is moving, it needs to pick a direction and, maybe, a destination that it can reach over a series of turns.
public MapSpace? Destination { get; set; }
public MapLevel.Direction? Direction { get; set; }
The easiest behavior to code would probably be for the monsters to move in a direction until they hit a wall or some other object and then change direction randomly as they need to. If they encounter a doorway, they can use it and travel along hallways. The Destination property will come into play later on if a monster needs to specifically search out a doorway or sit on an item like gold.
That all changes if the monster detects the player in the same room and is aggressive, then the monster will need to pursue the player until it “loses sight” of them. In my plays of the game, that generally seems to happen once the player leaves the room but it could also happen once a certain distance is achieved. Monsters also tend to move slower than the player so it’s usually easy to outrun them.
This gives us an algorithm something like this:
- Monster spawns
- Is player in the same room?
- Is monster aggressive?
- Yes – pursue and then evaluate player presence again.
- No – do nothing
- Is monster aggressive?
- Choose direction to move.
- If random number is within Inertia setting, move until blocked.
- If blocked, choose new available direction, preferably 90 or 270 degrees off original.
- If a door is in one of the adjacent spaces, go through it.
This leaves a possibility that two monsters could meet in a hallway going in the opposite directions. One or both of them will need to turn around unless we want them to fight.
The Code
The new Monster.Inertia property is added to the class constructors and, for now, I gave each monster template an inertia setting of 50. So, 50% of the time, the monster should decide to pause where it is and then it takes another random number roll higher than than 50 to get it moving again.
A couple extra functions in the MapLevel class went public; the Monster class now needs GetDirection90() and GetDirection270() to calculate relative directions. There’s no harm opening them up. I also added GetDireciton180() just for the sake of completion.
I wrote the new Game.MoveMonster() method by copying MovePlayer() although monsters don’t do most of what the player does so there was a lot of code to be discarded. You can see the entire code on Github but the algorithm breaks down like this:
- Compare random number to Inertia setting. If it exceeds inertia, continue.
- Get the adjacent spaces to the monster with CurrentMap.SearchAdjacent().
- Find out how far away the player is from the monster. A new constant in the Game class provides a max pursuit distance.
private const int MAX_PURSUIT = 7;
If the player is within the pursuit distance, the code cycles through the adjacent spaces to determine which one is closer to the player than the current space and habitable. It sets that space as the one to move to and sets the monster’s Direction property at the same time. If no space is closer than the current location or the player is too far away to pursue, the monster picks a direction at random.
Since the monster might have just changed direction, it verifies again that the intended space is available and moves there if it is.
If the space is not available because the player or another monster is there, the monster currently sets its direction randomly to right or left based on the previous direction and puts off any movement until the next turn.
As the code comments above indicates, this is the point where the monster need to decide if it’s going to attack. For right now, I’m just having the monsters change direction but the attack will be programmed here a bit later. There’s also the option to let the monster attack other monsters.
Then, the Game.CompleteTurn() method calls MoveMonster() after the player takes their action.
foreach (Monster monster in CurrentMap.ActiveMonsters)
MoveMonster(monster);
Let’s see the results. The monsters started to crowd around me and block movement so I programmed in a basic attack function to clear them which I’ll talk about in the next section.
Originally, the monsters were not showing up in hallways so MapLevel.MapText() needed to be overhauled. It turned out that when the player wasn’t inside a room, it was just showing the map character. I hadn’t touched this function in awhile so it was a great chance to review it and make some much needed improvements.
It’s getting crowded in here …
I also needed to make some serious changes to Game.MoveCharacter() (which I have now renamed MovePlayer() ) as it wasn’t completing the turn if the player ran into a monster. Now that the monsters were able to zero in on the player, it was getting a little difficult to get around the map so I decided to add a basic player attack to the game so the player could clear a path if necessary.
else if (adjacent[direct].DisplayCharacter != null)
{
Monster opponent = (from Monster monster in CurrentMap.ActiveMonsters
where monster.Location == adjacent[direct]
select monster).First();
Attack(CurrentPlayer, opponent);
// Player turn completed.
turnComplete = true;
}
Giving the monster a Location property lets us easily match the actual Monster object to the character on the map that the player is running into. Then we need a basic Attack() method. The actual fighting calculations will involve such things as armor class and strength but for now, I’ll start with a 50% chance of scoring a hit and then let each attack take up to half of the monster’s maximum hit points.
private void Attack(Player Attacker, Monster Defender)
{
// Basic attack method to get monsters out of the way. In progress.
// Start with a 50% chance of landing a punch.
bool hitSuccess = rand.Next(1, 101) > 50;
int damage = 0;
// Up to 50% of the monster's HP.
if (hitSuccess)
{
cStatus = $"You hit the {Defender.MonsterName.ToLower()}.";
damage = rand.Next(1, (int)(Defender.MaxHP / 2) + 1);
}
else cStatus = $"You missed the {Defender.MonsterName.ToLower()}.";
Defender.HPDamage += damage;
// If the monster has been defeated, remove it from the map.
if(Defender.CurrentHP < 1)
{
if(Defender.Location != CurrentPlayer.Location)
CurrentMap.LevelMap[Defender.Location!.X,
Defender.Location.Y].DisplayCharacter = null;
CurrentMap.ActiveMonsters.Remove(Defender);
cStatus = $"You defeated the {Defender.MonsterName.ToLower()}.";
}
}
… and then everything changed.
I found that monsters were randomly disappearing and I couldn’t track down the cause. It seemed to be happening only once every couple thousand moves. Finally, I decided to do a major refactor of the code and let the MapText() function do all the player and monster placements based on the Location properties of each. It was better to refresh all the display locations once instead of managing it throughout the program and it was being done a lot.
Another problem this solved was having to update both the MapSpace.DisplayCharacter and the ActiveMonsters list each time a monster moved. I’d been doing this for inventory and I didn’t want to have to do it for monsters, too. I wanted to update and consult the ActiveMonsters list and then have that update the map as needed. This still meant I needed to locate a specific monster object by map location.
public Monster? DetectMonster(MapSpace Location)
{
Monster? foundMonster = (from Monster monster in ActiveMonsters
where monster.Location!.X == Location.X
&& monster.Location.Y == Location.Y
select monster).FirstOrDefault();
return foundMonster;
}
Once this was done, I took a serious look at the way inventory is handled on the map. If monsters could be maintained at class level, inventory should be as well instead of being written to two properties in the MapSpace class. This meant that the inventory class would need a Location property. It was probably best to just make gold an inventory item, too, even if it needs to be handled differently when the user picks it up.
- Inventory.InvCategory gets a new member for Gold.
- InventoryDisplay() needed to be updated to exclude gold.
- The MapLevel class gets a new List to handle inventory.
public List<Inventory> MapInventory = new List<Inventory>();
The map still needs to add gold and other inventory in separate steps when it’s being created because I only want one gold deposit per room.
The MapLevel class gets a new method called RefreshMapLocations() that will clear the ItemCharacter and DisplayCharacter properties from the map array and then update the player, monster and inventory locations as needed.
public List<MapSpace> CurrentMapItems()
{
List<MapSpace> retList = new List<MapSpace>();
// Add the current player's location.
if(CurrentPlayer.Location != null)
retList.Add(CurrentPlayer.Location);
// Add the monsters.
foreach (Monster monster in ActiveMonsters)
retList.Add(monster.Location!);
// Add inventory
foreach (Inventory item in MapInventory)
retList.Add(item.Location!);
return retList;
}
public void RefreshMapLocations()
{
MapSpace PlayerLocation = CurrentPlayer.Location!;
// Clear existing spaces on map.
foreach (MapSpace space in levelMap)
{
space.ItemCharacter = null;
space.DisplayCharacter = null;
}
// Put player on map.
if(PlayerLocation != null)
levelMap[PlayerLocation.X, PlayerLocation.Y].DisplayCharacter =
Player.CHARACTER;
// Place monsters.
foreach (Monster monster in ActiveMonsters)
levelMap[monster.Location!.X, monster.Location.Y].DisplayCharacter =
monster.DisplayCharacter;
// Place inventory
foreach (Inventory item in MapInventory)
levelMap[item.Location!.X, item.Location.Y].ItemCharacter =
item.DisplayCharacter;
}
Since both monsters and inventory are very limited, clearing the existing markers from the map and replacing them takes no time at all. Notice that the MapLevel class now has a reference to the current player which it needs for the player’s location. It has a list of the monsters and inventory so I figured a simple reference to the player object shouldn’t be a problem and I got tired of passing the value in all the time.
I don’t know why I didn’t think of it before the but new MapLevel.GetOpenSpace() function returns a random available space within any one of the rooms to place an item or monster with an option to return spaces within hallways. It can be called from anywhere in the MapLevel or Game class so long as the map has been defined.
After this, I rewrote much of the MapLevel class to review and update all the references and made significant changes in the Inventory and Game classes, too. Any code that worked with moving elements needed to be reviewed. I also spotted a number of other possible improvements and cleaning of old code.
- The placement of the Amulet is now being done in the MapLevel class since it’s now aware of the current game level.
- Methods that no longer needed to be separate like AddStairway() were merged.
- All references to the MapSpace.MapInventory property need to go away and then it needs to be eliminated.
- PickupGold() was merged into AddInventory().
I’ll probably continue to spot methods that need to be replaced and removed as I go.
What’s next?
We now have a game where the player can move around the dungeon and between levels, collect items, eat and bash some monsters around. It would be nicer if the monsters could fight back but, before we move on to completing the fight mechanics, I want to get the management of some of the player stats in place. The hit points, strength and experience need to be managed and the player needs to be able to take armor on and off. The status messages are also too easy to miss so I’m going to make them a little more prominent.
Finally, as the refactoring in this chapter has shown, this program is getting a little complex and different features are starting to interact in unexpected ways. It’s time for a more complete testing and a playthrough or two.
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