<< Back

Rooms, Monsters, and Files

This time, we'll be building on what you have learned already to make a few new classes. Then, we'll learn about interacting with the filesystem in order to create data files and save the game.

We should probably get some basic things figured out, like a player character and some monsters. We'll start with the PlayerCharacter class.

These seem like the bare minimum things we want the player to track. All of them have both get and set accessors, which means they can be freely retrieved and changed. Some of them could probably be restricted, but for now let's not worry about that.

For monsters, it seems worth having a name. We won't have them gain experience, and instead we'll give them set levels.

This room will be another record. We'll make it contain an entry message (those messages from Program.cs), a Monster Level, and the monsters to possibly encounter weighted for their encounter rate.

Back in Dungeon.cs, we're going to change the Rooms property to use the new Room class instead of string.

We had to change the property, the constructor, and even the ShowRoom method (which we have removed and replaced with the pictured property). At this point, Program.cs is broken and won't compile. We'll get to that soon, but there are a few things we should look at first.

XML

This section will concern loading and saving files.

This is an XML file. XML is a common format for storing and transmitting data, because you have a lot of freedom in designing your data format, and because it is easy to read the data. Other formats such as JSON have become more popular in recent years, but the tools C# provides for XML are unimaginably beyond all other data format tools.

The first line of this file is the XML header. This is basically ignored by most tools, but can be useful sometimes. Below that is the root node, which we have named Monsters. Inside of that are some Monster elements. You might notice that these are very similar to the Monster class we designed above, but missing some things. This is intentional. Go ahead and make this file, because we'll use it soon. You can also add as many more monsters as you like.

Back in your Monster class, add a new static method named "FromXml" - this one will take a single parameter, "element", and return an instance of the Monster class. The type of the element parameter is "XElement" which is a class used to read an XML element. This will require a using at the top of the file, so don't leave that out!

The purpose of this method will be to convert an "element" of XML to a Monster object, if it is a valid Monster.

Not much code has changed, and yet some of this is complicated and worth explaining. We have here an if like any other. The first difference I want to draw your eyes to is the exclamation point. This is the logical NOT operator - it inverts any boolean value, from true to false or vice versa. We're using that to make this if mean "If the name of this element is not equal to Monster" - if this code is run on something other than a Monster XML node, we want to run the next line. If you mouse over the "Name" property or the Equals method, you may notice that instead of a string, the type of these values is "XName" - this is a special XML name class which strings automatically turn into when needed by means you do not need to know about yet. The final line of code is the most "new" - the throw keyword says you want to cause an error to occur. Program execution will immediately halt until this error is dealt with. The most common form of error is the Exception, which ArgumentException is a kind of. This exception represents some kind of generic problem with an argument. We add a message to it to explain what the problem is, as well as indicate which argument was the problem (even though there is only one). A couple of times here you may have seen nameof() used - this gets the actual name of the code symbol inside of it as a string. This helps you avoid typos, as well as solving other issues we may go into in the future.

Okay, we have created four variables. These all correspond to the attributes in the XML file. We assign them by calling the "Attribute" method of the "element" object, and pass in the name of the attribute to retrieve. We then use ?.Value to access the "Value" property of that attribute object. The question mark here is important: The "Attribute" method returns a nullable "XAttribute" - this appears as XAttribute? when you mouse over it. Nullable, as we've gone over before, means that the variable can be empty, containing nothing at all. If you were to try accessing the "Value" attribute on nothing, an exception will be thrown! Adding the question mark before the dot tells the code to just say, "if there isn't anything just assume that the value is null". This means that now our variables are now nullable as well.

This is the most complicated change yet, but it isn't as complex as it looks. We need to validate the four values we just retrieved from the XML and make sure they are the correct type. The name is simple: a name is required, so if it comes back as null, we throw an exception.

The other three will all follow a single pattern only slightly more complex than the first: We now throw an exception if the value of that attribute is null or is not a valid integer. The static TryParse method on the int type itself returns a boolean, which will be true only if the input value is a valid integer. If so, the second parameter recieves a value. This is a special kind of parameter called an out parameter: it acts as a second return value for the method. It has to be prefixed with the out keyword, and if you are not using an existing variable, you must include the type of the new variable this parameter creates from this method call onward. We use var instead of the explicit type as usual.

Down below, we assign the values we retrieved to the Monster instance we return using a syntax you haven't seen before: If you construct an object with the new keyword, you can include curly braces before the final semicolon, and within them assign the settable properties. If the constructor has no parameters, you can even leave off the parentheses like we did here.

The Monster class is now complete! However, we do not yet use the new method or even the Monster class itself. We'll get to that later, but we have a few more things to accomplish first.

This is the first row of the new file you need to create, Dungeon.xml. This file will be rather complicated, and you'll be mostly designing it yourself. Right now, I'm matching the basic structure of the array we made in Program.cs: I'm making rows that I'm filling with <Null/> tags to mark nulls. Remember, we're just making up this format on the fly. Where we want a room, we're making a Room tag with an EntryMessage and a MonsterLevel, and inside of that we're adding "PossibleMonster" tags with a weighting and a value each. I'm going to add the rest of the dungeon, but feel free to make yours completely differently structured. At the top of the file I've included the starting position in the Dungeon tag. As long as your starting position is in a valid tile, everything will be fine. Because we have code to tell which direction you're allowed to go, you can imagine some more interesting room descriptions if you want.

Let's take care of the loading of the Room class first, back in Room.cs.

We've added a method to our Room class, with the same name as the one in the Monster class. The first two elements and the start of this method will be very similar to the ones there.

The first thing we'll do is add a new parameter to this method. The way we designed the XML, the only thing the rooms contain is the name of a monster from the Monsters XML file. After we load that, we'll pass the monsters we know about into here so we can search for the ones in this room. You should understand everything here up until the 26th line, so we'll skip to there. First, we create a variable to hold the PossibleMonster elements in this Room's XML. The Elements method gets all child elements that match the name you pass in as some kind of enumerable. Next, we create a list to add the weighted monsters to.

In the first loop, we go through the PossibleMonster XML nodes, and for each one, we attempt to get the weight and the name of the monster through means you recognize. We then attempt to find a monster with a matching name from the list of monsters we know about using the FirstOrDefault method, which when it fails to find anything will return null. This is another method that uses a lambda as a parameter: an unnamed function that checks each monster to see if one matches the name we're looking for. We do a lot of validation here, but if we successfully get everything we're looking for, we apply the weight to the monster and add it to our possibleMonsterEncounters list.

After that is a second foreach loop, used to process <Null> nodes within the Room. Nulls here will be used to specify the weighting for no monster encounter at all. That's why these also have weights. It works the same way as the previous section.

Over the course of this, I realized that I forgot something in the Randomization class: If there can be a case where no monster is encountered in an area, our randomization method needs to be able to handle nulls. The following changes should solve that:

Okay, back to the XML fun!

We now need to turn our eyes back to Dungeon.cs, which will need yet another FromXml static method.

We know we need the monsters here, so we can add that parameter immediately.

Now we have our basic validation and start positions done, so we move on to the rooms.

First, we get all of the Row elements in the Dungeon, and we turn them into an array using the ToArray method available on any IEnumerable. Then, we make the array of nullable rooms that we'll be building up and passing into the Dungeon constructor. The variable passed into the first set of square braces is how many elements there are in that direction. We know how many row nodes we have, so we can set that now.

Next, we have a for loop. Unlike the while you used early on which works with a single variable, and the foreach loop you used recently which works with an IEnumerable, this loop works with a variable. Defining it involves three sections surrounded by semicolons: The first defines a variable (i), the second defines a limit, after which the loop will stop (i is less than the number of Row elements), and what to do before or after each loop (i++ means after each loop, increase i by 1).

Inside this loop, we get all of the elements inside the Row element, and turn them into an array as well. That gives us how many rooms across this row is, so we use it to assign the ith row in our rooms array to be a new array the length of the number of rooms in this row. Then, we start a new loop to go through the rooms in this row.

Inside the inner loop, we save the current room node to a variable. We then check if the node is a Null node, and if so, we add a null in position [i][j] of the rooms array. If the node is not a Null, we ask the FromXml node we added to the Room class to deal with it. This handles any validation we might need.

Once all of the looping is completed, our rooms array is complete, and can be used in the constructor. We have a dungeon!

We still have a bit more stuff to display, so let's write two simple methods:

Now we have just two more steps. First, you'll need to check the properties of your XML files in Visual Studio's Solution Explorer.

We need to set the Build Action to Content, and the Copy to Output Directory to Always. This means that when your code compiles, these XML files will be in the same folder as your compiled code. You need to do this for both Dungeon.xml and Monsters.xml.

All that's left is to hook it all up in Program.cs.

We've added a new using, and then a bunch of code for loading the files. On line 6, part of the code reads typeof(Program).Assembly.Location - we are accessing the location property on the assembly property of the type of the program itself. First off, the Program.cs file is actually generating a class named Program behind the scenes. The typeof() allows us to access meta information about the type itself, which we use to look at the assembly containing it. An "assembly" in C# is a unit of compiled code, such as a "dll" or "exe" file. When we access it's location, we get the full file path to the DLL itself. We wrap this access with a method called Path.GetDirectoryName() - the Path class has several useful methods specifically for dealing with filesystem paths, and in this case it gets us the folder containing the DLL and, importantly, our XML files.

I'll spare you a description of all of the validation code, you understand that at this point. We just want to be absolutely sure that our code is running right. On line 12, we call XDocument.Load() a method that opens an XML file at a given file path as the kinds of objects we've been working with so far. We're passing into it the result of calling another Path method, this time named "Combine" - this method puts the correct kind of slashes between the strings you pass in to make sure that they are a valid path - we use it to target the Monsters.xml we created. We then make a list to put our monsters into, and loop through all the elements inside the root node of the XDocument. The root node, as you might expect, is <Monsters> from the XML file. We use some question marks to handle places where we might end up with a null, terminating in ?? Enumerable.Empty<XElement> - this says that if we have a null here, just use a completely empty IEnumerable instead, which will make the loop be skipped with no errors. We pop every element we find into the Monster class's FromXml method, and we now have a monster to add to our list of monsters!

Loading the dungeon is even easier, so if you've made it this far you should understand it already.

We then update the chain of WriteLine calls inside the main game loop, and our program is done! Let's run it.

...

Oh no! The code threw an exception! You're going to have to make a change in multiple places in multiple files, and I'm only going to show you the hardest one to find! Apparently, even though the names look the same to us, comparing two XName values does not work how we expected! Every place where we attempt to check if an XName is equal to a string we pass in, we'll have to make it compare with the name's LocalName instead. Here is the example:

There should only be about three of these, but when you find and fix them all, the program should run and behave almost the same as it used to, but now you can change how it behaves just by editing some XML files, and you can see yourself encounter random monsters!

This has been a long one. Hopefully the next one will not be. In that one, we'll try to implement combat, and allow you to save your game!