Press "Enter" to skip to content

Lesson 14.3: Read World (Location) data from an XML file

I originally wanted to convert all the factories to use XML files in this lesson. However, there is a lot of extra work to handle the location image files. So, this lesson will cover the locations.

The next lesson will cover the monsters. There are some extra changes we’ll need to make for the loot items. A third lesson will handle all the other factory classes – their changes are simple.

Step 1: Create Engine\GameData\Locations.xml

This is the XML file for the Location object data.

Notice there is an attribute “RootImagePath” at the top-level “Locations” node. Because all the images will be in this one directory, I put this attribute here. We’ll read it once and add it to all the image file names in the “Location” nodes.

Because the Description value will be so long, I made it a child node, instead of making it an attribute. This is only a personal preference, so the “Location” node would not be an extremely long line.

Because “Description” is a child node, I put the text in between its opening and closing tags, instead of putting the description in another attribute. The text is surrounded with “

Each “Location” node has the “Description” child node. It can have a “Trader” node, and “Monsters” or “Quests” children.

NOTE: Remember to right-click on Locations.xml and set its “Copy to Output Directory” property to “Copy Always”.

Locations.xml

<?xml version="1.0" encoding="utf-8" ?>
<Locations RootImagePath="/Images/Locations/">
  <Location X="-2" Y="-1" Name="Farmer's Field" ImageName="FarmFields.png">
    <Description><![CDATA[There are rows of corn here, with giant rats hiding between them.]]></Description>
    <Monsters>
      <Monster ID="2" Percent="100"/>
    </Monsters>
  </Location>
  <Location X="-1" Y="-1" Name="Farmer's House" ImageName="Farmhouse.png">
    <Description><![CDATA[This is the house of your neighbor, Farmer Ted.]]></Description>
    <Trader Name="Farmer Ted"/>
  </Location>
  <Location X="0" Y="-1" Name="Home" ImageName="Home.png">
    <Description><![CDATA[This is your home.]]></Description>
  </Location>
  <Location X="-1" Y="0" Name="Trading Shop" ImageName="Trader.png">
    <Description><![CDATA[The shop of Susan, the trader.]]></Description>
    <Trader Name="Susan"/>
  </Location>
  <Location X="0" Y="0" Name="Town Square" ImageName="TownSquare.png">
    <Description><![CDATA[You see a fountain here.]]></Description>
  </Location>
  <Location X="1" Y="0" Name="Town Gate" ImageName="TownGate.png">
    <Description><![CDATA[There is a gate here, protecting the town from giant spiders.]]></Description>
  </Location>
  <Location X="2" Y="0" Name="Spider Forest" ImageName="SpiderForest.png">
    <Description><![CDATA[The trees in this forest are covered with spider webs.]]></Description>
    <Monsters>
      <Monster ID="3" Percent="100"/>
    </Monsters>
  </Location>
  <Location X="0" Y="1" Name="Herbalist's Hut" ImageName="HerbalistsHut.png">
    <Description><![CDATA[You see a small hut, with plants drying from the roof.]]></Description>
    <Quests>
      <Quest ID="1"/>
    </Quests>
    <Trader Name="Pete the Herbalist"/>
  </Location>
  <Location X="0" Y="2" Name="Herbalist's Garden" ImageName="HerbalistsGarden.png">
    <Description><![CDATA[There are many plants here, with snakes hiding behind them.]]></Description>
    <Monsters>
      <Monster ID="1" Percent="100"/>
    </Monsters>
  </Location>
</Locations>

Step 2: Modify Engine\Models\World.cs

The original AddLocation function took several parameters, constructed a Location object, and added the Location object to _locations. Now, we’re going to completely construct the Location object in WorldFactory. So, I changed AddLocation to accept the completely-built Location object.

World.cs
using System.Collections.Generic;

namespace Engine.Models
{
    public class World
    {
        private readonly List<Location> _locations = new List<Location>();

        internal void AddLocation(Location location)
        {
            _locations.Add(location);
        }

        public Location LocationAt(int xCoordinate, int yCoordinate)
        {
            foreach(Location loc in _locations)
            {
                if(loc.XCoordinate == xCoordinate && loc.YCoordinate == yCoordinate)
                {
                    return loc;
                }
            }

            return null;
        }
    }
}

Step 3: Modify Engine\Factories\WorldFactory.cs

The changes here are like what we did to the ItemFactory class.

We read the XML file, get the RootImagePath value, and parse the Location nodes.

One thing to notice is the ImageName value in the call to the Location constructor (line 51). Before, the image files were included as resources in the compiled program. The value looked like this:

$”/Engine;component/Images/Locations/{imageName}”

Now, it uses a directory path, relative to where the game is running (because of the “.” at the beginning of the file name string. In the next step, we’ll change the project to read the images from external files, instead of program resources.

This will let you send out a new Locations.xml file, or new images, to expand the game world – without needing to rebuild the program and have the player completely re-install the game.

WorldFactory.cs
using System.IO;
using System.Xml;
using Engine.Models;
using Engine.Shared;

namespace Engine.Factories
{
    internal static class WorldFactory
    {
        private const string GAME_DATA_FILENAME = ".\\GameData\\Locations.xml";

        internal static World CreateWorld()
        {
            World world = new World();

            if(File.Exists(GAME_DATA_FILENAME))
            {
                XmlDocument data = new XmlDocument();
                data.LoadXml(File.ReadAllText(GAME_DATA_FILENAME));

                string rootImagePath =
                    data.SelectSingleNode("/Locations")
                        .AttributeAsString("RootImagePath");

                LoadLocationsFromNodes(world,
                                       rootImagePath,
                                       data.SelectNodes("/Locations/Location"));
            }
            else
            {
                throw new FileNotFoundException($"Missing data file: {GAME_DATA_FILENAME}");
            }

            return world;
        }

        private static void LoadLocationsFromNodes(World world, string rootImagePath, XmlNodeList nodes)
        {
            if(nodes == null)
            {
                return;
            }

            foreach(XmlNode node in nodes)
            {
                Location location =
                    new Location(node.AttributeAsInt("X"),
                                 node.AttributeAsInt("Y"),
                                 node.AttributeAsString("Name"),
                                 node.SelectSingleNode("./Description")?.InnerText ?? "",
                                 $".{rootImagePath}{node.AttributeAsString("ImageName")}");

                AddMonsters(location, node.SelectNodes("./Monsters/Monster"));
                AddQuests(location, node.SelectNodes("./Quests/Quest"));
                AddTrader(location, node.SelectSingleNode("./Trader"));

                world.AddLocation(location);
            }
        }

        private static void AddMonsters(Location location, XmlNodeList monsters)
        {
            if(monsters == null)
            {
                return;
            }

            foreach(XmlNode monsterNode in monsters)
            {
                location.AddMonster(monsterNode.AttributeAsInt("ID"),
                                    monsterNode.AttributeAsInt("Percent"));
            }
        }

        private static void AddQuests(Location location, XmlNodeList quests)
        {
            if(quests == null)
            {
                return;
            }

            foreach(XmlNode questNode in quests)
            {
                location.QuestsAvailableHere
                        .Add(QuestFactory.GetQuestByID(questNode.AttributeAsInt("ID")));
            }
        }

        private static void AddTrader(Location location, XmlNode traderHere)
        {
            if(traderHere == null)
            {
                return;
            }

            location.TraderHere =
                TraderFactory.GetTraderByName(traderHere.AttributeAsString("Name"));
        }
    }
}

Step 4: Modify the .png files in Engine\Images\Locations

Right-click on these files, change “Build Action” to “None” and “Copy to Output Directory” to “Copy Always” – just like we do with the XML data files for the factories.

NOTE: You can shift-click to select all the image files and change all their “Build Action” and “Copy to Output Directory” values at the same time.

Step 5: Create a new folder WPFUI\CustomConverters and a new class WPFUI\CustomConverters\FileToBitmapConverter.cs

When the location images were project resources, it was simple to display them in an UI Image control. Now that they are external files, we need to do an extra step to get them into the Image control.

With WPF, it is common to create custom converter classes when you want to use data differently from how it is stored. A common converter is converting a Boolean property to a Visibility value (Visible, Hidden, or Collapsed). This way, you can hide or show different parts of the UI, based on a property value.

Another common one is to convert a bound Boolean property to display a text value of “Yes” or “No”.

This converter class will let us bind the Image control to a file path (the location image file), read the file from disk, and convert it to a BitmapImage object – which can be displayed in a WPF Image control.

Custom converter class must implement the “IValueConverter” interface (line 9), which requires the class to have Convert and ConvertBack functions.

We are only going to convert from a file path and name to a BitmapImage, so we can have the ConvertBack function (lines 31-34) just return null.

In the Convert function, we try to cast the “value” parameter to a string and store it in the “filename” variable (line 16). The “value” parameter is the value of the bound property in the UI’s Image control, for this program.

If the value is a string, we’ll see if we have a DIctionary entry whose key is the filename.

Notice the “_locations” dictionary (lines 11-12) is static. That means it will be shared by everything that calls this FileToBitmapConverter class. This is a way to cache the images, so we don’t need to re-read the file from disk each time the player returns to a location.

If the location (image file path and name) is not already in the Dictionary, we create a new BitmapImage object from the file (lines 24-25) and add the location file name and BitmapImage to “_locations”, so it will be available the next time the player moves to this location.

Finally, on line 28, we get the BitmapImage object from the “_locations” cache, to populate the Image control’s Source property.

FileToBitmapConverter.cs
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Windows.Data;
using System.Windows.Media.Imaging;

namespace WPFUI.CustomConverters
{
    public class FileToBitmapConverter : IValueConverter
    {
        private static readonly Dictionary<string, BitmapImage> _locations =
            new Dictionary<string, BitmapImage>();

        public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
        {
            if(!(value is string filename))
            {
                return null;
            }

            if(!_locations.ContainsKey(filename))
            {
                _locations.Add(filename,
                               new BitmapImage(new Uri($"{AppDomain.CurrentDomain.BaseDirectory}{filename}",
                                                       UriKind.Absolute)));
            }

            return _locations[filename];
        }

        public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
        {
            return null;
        }
    }
}

Step 6: Modify WPFUI\App.xaml

To use the FileToBitmapConverter in a WPF window, we’ll add it as an Application Resource (lines 7-9). Now, we can use this converter in any WPF control in this project.

On line 4, we create “converters” as the XML namespace for our customer converters. Then, on line 8, we say, “In the ‘converters’ namespace, we have the FileToBitmapConverter. Create a key (name) we can use when we want to use this converter in a WPF window/control.”

App.xaml
<Application x:Class="WPFUI.App"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:converters="clr-namespace:WPFUI.CustomConverters"
             StartupUri="MainWindow.xaml">

    <Application.Resources>
         <converters:FileToBitmapConverter x:Key="FileToBitmapConverter"/>
    </Application.Resources>

</Application>

Step 7: Modify WPFUI\MainWindow.xaml

Finally, add the converter to the Image control (lines 115-116). Now, when the program tries to bind the CurrentLocation.ImageName value to the Image control, it will first convert it with FileToBitmapConverter.Convert().

This is how we go from a file on disk to a graphic displayed in the UI.

MainWindow.xaml (lines 110-116)

<Image Grid.Row="1"
                           HorizontalAlignment="Center"
                           VerticalAlignment="Center"
                           Height="125"
                           Width="125"
                           Source="{Binding CurrentLocation.ImageName,
                                            Converter={StaticResource FileToBitmapConverter}}"/>

Step 8: Test the game

Leave a Reply

Your email address will not be published. Required fields are marked *