Jump to content

  • Log In with Google      Sign In   
  • Create Account





State Machines in Games – Part 4

Posted by frob, 13 March 2013 · 1,066 views

Back to the castle exploration game. We're still working with an single state machine in the AdaptableStateMachine engine. This will be our third concrete example of a state machine.

Recap:

In Part 1 we covered the definition of a state machine and implemented a simple machine with a case statement.

In Part 2 we created a common state machine interface, created a state machine runner for the interface, and a boring state machine concrete class.

In Part 3 we left the state machine runner unchanged, created new state machine concrete classes for a castle map. Finally it looks like a game. A single state machine game.

Now in Part 4 we will make the state machine game data driven.

What Does Data Driven Mean?

So far every state machine and every state has been written in C#. If I wanted to make changes I needed to modify the source code, recompile, and test the app. For a small map and a single developer this is not a bad thing.

But what happens when the app grows?

Let's imagine I hire a person to design my levels. That person is not a programmer. I don't want them mucking about in my C# files, and I don't want to teach them how to read and write C#. So what do I do?

Simple: I create a save file that contains all the information describing the level.

By loading the level at runtime I can allow non-programmers to develop the game. It means level designers can modify rooms, put different things in different locations, and otherwise improve the game without touching code. It means another programmer can implement game objects like ropes and bottles and such (should be in Part 7, I think) and the level designer can manipulate those in the world without modifying code. If I had artwork, artists could create new art and designers could put it in the world without touching code.

If this were a major game we might have ten or twenty people working on the game. Only a quarter of those people would be programmers. Everyone else would be modifying the game data rather than modifying the game code.

Data Driven means only the programmers need to touch the code, and everyone else gets to use data files to modify the game.

If we wanted to be fancy we could add a mechanism to reload data files while the game is running. That would let designers and artists and animators and modelers iterate on their work much faster, saving many hours of development time.

If we wanted to get REALLY fancy would could use operating system events to notify us when a change is made and automatically reload the resources at runtime.

On to the Code!

Once again I am using IState and IStateMachine interfaces for my machine.

And just like before, the entire game is managed by these same two files.

The States

States are only slightly modified from last time.

Last time I stored the state's name, description, and neighbors.

This time I add a unique name key and a set of flags.

The flags are: None, Enter, and Exit. This allows me to code in multiple exit nodes. I am still required to have only one entry node because that is just how state machines work; you need to start somewhere.

Next I added the functions ReadXml and WriteXml. These two functions save and load my five elements (unique name, flags, visible name, description, and neighbors) into an xml file. Because it is basically free I chose to implement them using the IXmlSerializable interface.

Since the state machine will need to create these objects from XML, I create a second constructor that takes an XmlReader and simply pass that on to the ReadXml function.

Finally I added some accessors and mutators (get and set functions) to help out the state machine.

The code:
    public class SavedMachineState : IState, IXmlSerializable
    {
        #region Members
        [Flags]
        public enum StateFlags
        {
            None = 0,
            Enter = 1,
            Exit = 2,
        }

        public string mKey;
        StateFlags mFlags;
        string mName;
        string mDescription;
        List<string> mNeighbors = new List<string>();
        public List<string> Neighbors { get { return mNeighbors; } }
        #endregion
        #region Constructors
        /// <summary>
        /// Manual constructor for default maze
        /// </summary>
        /// <param name="uniqueKey">unique name for the stateFlags</param>
        /// <param name="flags">flags to indicate enter nodes and exit nodes</param>
        /// <param name="name">name to show to the user</param>
        /// <param name="description">text to show for the description</param>
        /// <param name="neighbors">unique keys for neighboring rooms, seperated by commas and not spaces</param>
        public SavedMachineState(string uniqueKey, StateFlags flags, string name, string description, string neighbors)
        {
            mKey = uniqueKey;
            mFlags = flags;
            mName = name;
            mDescription = description;
            mNeighbors.AddRange(neighbors.Split(','));
        }
        /// <summary>
        /// Constructor to create an object from a save file
        /// </summary>
        /// <param name="reader">xml stream to read from</param>
        public SavedMachineState(XmlReader reader)
        {
            ReadXml(reader);
        }
        #endregion
        #region Helper Functions
        public bool IsStartState { get { return (mFlags & StateFlags.Enter) != StateFlags.None; } }
        public bool IsExitState { get { return (mFlags & StateFlags.Exit) != StateFlags.None; } }
        public string Key { get { return mKey; } }
        public bool IsMyName(string nameToTest)
        {
            //TODO: Add shortcuts to names.  For example, allow "Great Hall", "Hall", etc.
            if (nameToTest.ToLower() == mName.ToLower())
                return true;
            if (nameToTest.ToLower() == mKey.ToLower())
                return true;
            return false;
        }
        #endregion
        #region IState Overrides
        public override string GetName()
        {
            return mName;
        }

        public override void Run()
        {
            // We don't do any fancy stuff, just print out where we are
            Console.WriteLine();
            Console.WriteLine(mDescription);
        }
        #endregion
        #region IXmlSerializable Members

        public System.Xml.Schema.XmlSchema GetSchema()
        {
            return null;
        }

        public void ReadXml(System.Xml.XmlReader reader)
        {
            reader.ReadStartElement();
            mKey = reader.ReadElementContentAsString("UniqueName","");
            string flagString = reader.ReadElementContentAsString("Flags","");
            mFlags = (StateFlags)Enum.Parse(typeof(StateFlags), flagString);
            mName = reader.ReadElementContentAsString("VisibleName", "");
            mDescription = reader.ReadElementContentAsString("Description", "");
            string neighborsString = reader.ReadElementContentAsString("Neighbors", "");
            mNeighbors.AddRange(neighborsString.Split(','));
            reader.ReadEndElement();
        }

        public void WriteXml(System.Xml.XmlWriter writer)
        {
            writer.WriteElementString("UniqueName", mKey);
            writer.WriteElementString("Flags", mFlags.ToString());
            writer.WriteElementString("VisibleName", mName);
            writer.WriteElementString("Description", mDescription);
            string neighbors = String.Join(",",Neighbors.ToArray());
            writer.WriteElementString("Neighbors",neighbors);
        }

        #endregion
    }

It might be a little intimidating if this were the first exposure you had to the code, but if you have been following along and since it has been gradually expanding it should seem to be just an incremental change.

The State Machine

The changes to the state machine were a little more dramatic.

As promised last time I was able to remove the saved mExit state. Since this is now part of the data (and because there can now be more than one) we don't want to keep this around in code.

I moved all the map construction code from the constructor to its own function: GenerateDefaultMap(). No point in throwing away all that work, and it allows us to generate and save a map when bootstrapping the tool chain. Now the constructor calls ImportFromXml(). If that fails we generate the default map (which saves a copy with ExportToXML() ) and then reload our newly created map. It then searches for an entry node. If it cannot fine one, we abort.

The ExportToXML() function creates an XML writer, loops through the list of states, and writes each state out using a helper function. The ImportFromXML() function creates an XML reader, and reads it in using a helper function. Those helper functions are the same IXmlSerializable interface we used on the state. This allows us to someday take advantage of the C# serialization system. Perhaps around Part 8 or so.

The WriteXML() function creates an XML element <room> </room> and then calls the state's WriteXML to fill in the details. The ReadXML() function verifies that we have a room node, calls the state's constructor with the XML reader, and adds the newly created state to the list.

The Code:
    public class SavedMachine : IStateMachine, IXmlSerializable
    {
        #region Members
        List<SavedMachineState> mStates = new List<SavedMachineState>();
        SavedMachineState mCurrent;
        #endregion
        #region Constructor
        /// <summary>
        /// Initializes a new instance of the FunnerMachine class.
        /// </summary>
        public SavedMachine()
        {
            try
            {
                ImportFromXML();
            }
            catch (Exception ex)
            {
                mStates.Clear();
            }

            if (mStates.Count == 0)
            {
                GenerateDefaultMap();
                ImportFromXML();
            }

            // Find the entry state
            for (int i = 0; i < mStates.Count; i++)
            {
                if (mStates[i].IsStartState)
                {
                    mCurrent = mStates[i];
                    break;
                }
            }
            if (mCurrent == null)
            {
                Console.WriteLine("\n\nERROR! NO ENTRY STATE DEFINED.");
                throw new Exception("No entry state defined in this state machine.  Cannot continue.");
            }
        }
        #endregion
        #region Helper Functions
        private void GenerateDefaultMap()
        {
            mStates.Clear();

            // Create all the fun states in our mini-world
            mStates.Add(new SavedMachineState("entryHall", SavedMachineState.StateFlags.Enter, "Grand Entrance", "You are standing in a grand enterance of a castle.\nThere are tables and chairs, but nothing you can interact with.", "staircase,outside"));
            mStates.Add(new SavedMachineState("staircase", SavedMachineState.StateFlags.None, "Grand Staircase", "The staircase is made from beautiful granite.", "eastWing,westWing,entryHall"));
            mStates.Add(new SavedMachineState("eastWing", SavedMachineState.StateFlags.None, "East Wing", "This wing is devoted to bedrooms.", "bedroomA,bedroomB,bedroomC,staircase"));
            mStates.Add(new SavedMachineState("westWing", SavedMachineState.StateFlags.None, "West Wing", "This wing is devoted to business.", "workroomA,workroomB,workroomC"));
            mStates.Add(new SavedMachineState("bedroomA", SavedMachineState.StateFlags.None, "Master Suite", "This is the master suite.  What a fancy room.", "eastWing"));
            mStates.Add(new SavedMachineState("bedroomB", SavedMachineState.StateFlags.None, "Prince Bob's Room", "The prince has an extensive library on his wall.\nHe also has more clothes than most males know what to do with.", "eastWing"));
            mStates.Add(new SavedMachineState("bedroomC", SavedMachineState.StateFlags.None, "Princess Alice's Room", "The princess has filled her room with a small compur lab.\nShe spends her days playing games and writing code.", "eastWing"));
            mStates.Add(new SavedMachineState("workroomA", SavedMachineState.StateFlags.None, "Study", "This is the study.  It has many books.", "westWing"));
            mStates.Add(new SavedMachineState("workroomB", SavedMachineState.StateFlags.None, "Bathroom", "Every home needs one", "westWing"));
            mStates.Add(new SavedMachineState("workroomC", SavedMachineState.StateFlags.None, "Do Not Enter", "I warned you not to enter.\nYou are in a maze of twisty little passages, all alike.", "passage"));
            mStates.Add(new SavedMachineState("passage", SavedMachineState.StateFlags.None, "Twisty Passage", "You are in a maze of twisty little passages, all alike", "passage"));
            mStates.Add(new SavedMachineState("outside", SavedMachineState.StateFlags.Exit, "Outside", "You have successfully exited the castle.", ""));

            ExportToXML();
        }
        public void ExportToXML()
        {
            XmlWriterSettings settings = new XmlWriterSettings();
            settings.Indent = true;
            settings.OmitXmlDeclaration = true;
            settings.NewLineHandling = NewLineHandling.Entitize;

            using (XmlWriter writer = XmlWriter.Create("GameRooms.xml",settings))
            {
                writer.WriteStartDocument();
                writer.WriteStartElement("SavedMachine");
                WriteXml(writer);
                writer.WriteEndElement();
                writer.WriteEndDocument();
            }           
        }
        public void ImportFromXML()
        {
            XmlReaderSettings settings = new XmlReaderSettings();
            settings.IgnoreWhitespace = true;
            XmlReader reader = XmlReader.Create("GameRooms.xml", settings);
            ReadXml(reader);
        }
        #endregion
        #region IStateMachine Overrides
        public override IState CurrentState
        {
            get { return mCurrent; }
        }
        public override string[] PossibleTransitions()
        {
            List<string> result = new List<string>();
            foreach (string state in mCurrent.Neighbors)
            {
                result.Add(state);
            }
            return result.ToArray();
        }
        public override bool Advance(string nextState)
        {
            foreach (SavedMachineState state in mStates)
            {
                if(state.IsMyName(nextState)
                    && mCurrent.Neighbors.Contains(state.Key))
                {
                    mCurrent = state;
                    return true;
                }
            }
            System.Console.WriteLine("Cannot do that.");
            return false;
        }
        public override bool IsComplete()
        {
            return mCurrent.IsExitState;
        }
        #endregion
        #region IXmlSerializable Members

        public System.Xml.Schema.XmlSchema GetSchema()
        {
            return null;
        }


        public void ReadXml(XmlReader reader)
        {
            bool isEmpty = reader.IsEmptyElement;
            reader.ReadStartElement();
            if (isEmpty) return;
            while (reader.NodeType == XmlNodeType.Element)
            {
                if (reader.Name == "Room")
                {
                    mStates.Add(new SavedMachineState(reader));
                }
                else
                    throw new XmlException("Unexpected node: " + reader.Name);
            }
            reader.ReadEndElement();
        }

        public void WriteXml(XmlWriter writer)
        {
            foreach (SavedMachineState state in mStates)
            {
                writer.WriteStartElement("Room");
                state.WriteXml(writer);
                writer.WriteEndElement();
            }
        }

        #endregion
    }
Run the Game

Now when I run the game, I select option 3 for this new state machine. Note that I had already included that option in Part 2's source code.

When it runs it attempts to load the save file, cannot find one, and generates the new room xml file. Then it plays the state machine as normal.

I don't care to play right now, I just want to read the XML.

Looking at the GameRooms.xml

Let's jump strait to the generated file:
<SavedMachine>
  <Room>
    <UniqueName>entryHall</UniqueName>
    <Flags>Enter</Flags>
    <VisibleName>Grand Entrance</VisibleName>
    <Description>You are standing in a grand enterance of a castle.
There are tables and chairs, but nothing you can interact with.</Description>
    <Neighbors>staircase,outside</Neighbors>
  </Room>
  <Room>
    <UniqueName>staircase</UniqueName>
    <Flags>None</Flags>
    <VisibleName>Grand Staircase</VisibleName>
    <Description>The staircase is made from beautiful granite.</Description>
    <Neighbors>eastWing,westWing,entryHall</Neighbors>
  </Room>
  <Room>
    <UniqueName>eastWing</UniqueName>
    <Flags>None</Flags>
    <VisibleName>East Wing</VisibleName>
    <Description>This wing is devoted to bedrooms.</Description>
    <Neighbors>bedroomA,bedroomB,bedroomC,staircase</Neighbors>
  </Room>
  <Room>
    <UniqueName>westWing</UniqueName>
    <Flags>None</Flags>
    <VisibleName>West Wing</VisibleName>
    <Description>This wing is devoted to business.</Description>
    <Neighbors>workroomA,workroomB,workroomC</Neighbors>
  </Room>
  <Room>
    <UniqueName>bedroomA</UniqueName>
    <Flags>None</Flags>
    <VisibleName>Master Suite</VisibleName>
    <Description>This is the master suite.  What a fancy room.</Description>
    <Neighbors>eastWing</Neighbors>
  </Room>
  <Room>
    <UniqueName>bedroomB</UniqueName>
    <Flags>None</Flags>
    <VisibleName>Prince Bob's Room</VisibleName>
    <Description>The prince has an extensive library on his wall.
He also has more clothes than most males know what to do with.</Description>
    <Neighbors>eastWing</Neighbors>
  </Room>
  <Room>
    <UniqueName>bedroomC</UniqueName>
    <Flags>None</Flags>
    <VisibleName>Princess Alice's Room</VisibleName>
    <Description>The princess has filled her room with a small compur lab.
She spends her days playing games and writing code.</Description>
    <Neighbors>eastWing</Neighbors>
  </Room>
  <Room>
    <UniqueName>workroomA</UniqueName>
    <Flags>None</Flags>
    <VisibleName>Study</VisibleName>
    <Description>This is the study.  It has many books.</Description>
    <Neighbors>westWing</Neighbors>
  </Room>
  <Room>
    <UniqueName>workroomB</UniqueName>
    <Flags>None</Flags>
    <VisibleName>Bathroom</VisibleName>
    <Description>Every home needs one</Description>
    <Neighbors>westWing</Neighbors>
  </Room>
  <Room>
    <UniqueName>workroomC</UniqueName>
    <Flags>None</Flags>
    <VisibleName>Do Not Enter</VisibleName>
    <Description>I warned you not to enter.
You are in a maze of twisty little passages, all alike.</Description>
    <Neighbors>passage</Neighbors>
  </Room>
  <Room>
    <UniqueName>passage</UniqueName>
    <Flags>None</Flags>
    <VisibleName>Twisty Passage</VisibleName>
    <Description>You are in a maze of twisty little passages, all alike</Description>
    <Neighbors>passage</Neighbors>
  </Room>
  <Room>
    <UniqueName>outside</UniqueName>
    <Flags>Exit</Flags>
    <VisibleName>Outside</VisibleName>
    <Description>You have successfully exited the castle.</Description>
    <Neighbors />
  </Room>
</SavedMachine>
This looks suspiciously like our original map. In fact, it is our original map, just saved out to xml.


To prove that the save and load system actually works, let's make some minor modifications to the map.

Attached Image

This will require a tiny modification to the entrance hall (poininting the neighbor to 'courtyard') and making three new rooms.

Easy enough, a few seconds in a text editor, copy/paste, a bit of wordsmithing, and I get this addition:
  <Room>
    <UniqueName>courtyard</UniqueName>
    <Flags>None</Flags>
    <VisibleName>Courtyard</VisibleName>
    <Description>The courtyard is decorated with many large trees and several marble benches.</Description>
    <Neighbors>entryHall,townGate</Neighbors>
  </Room>
  <Room>
    <UniqueName>townGate</UniqueName>
    <Flags>None</Flags>
    <VisibleName>Town Gate</VisibleName>
    <Description>You arrive at the gate of the town.  Ahh, to be home again.\n\nNOTICE: The guards will not let you return to the castle if you leave.</Description>
    <Neighbors>courtyard,village</Neighbors>
  </Room>
  <Room>
    <UniqueName>village</UniqueName>
    <Flags>Exit</Flags>
    <VisibleName>Quaint Village</VisibleName>
    <Description>You return to your village.  You won't soon forget your experiences in the castle.</Description>
    <Neighbors />
  </Room>
Modify the save file, reload the app, and automatically I have three new rooms.

Since I haven't done this yet, I'll just say that a map editor and more complex maps are left as an exercise to the reader. You can do that if you want to. I'm busy writing code for parts 5, 6 and 7.

And speaking of Part 5...

Always Leave Them Wanting More

So now we have a game world that is data driven. Any programmer or level designer can modify the GameRooms.xml file to make the map as complex as you want. I recommend creating a simple tool if you plan on making more than a few dozen rooms, but that is really up to you.

Where are we going next?

I promised we'd get in to AI.

AI in a text adventure is nice, but it isn't very interactive.

So Part 5 will have an actual VIDEO game. With graphics. And an update loop. And individual actors that have an AI. But not so much for gameplay since all we are testing is AI. We'll also have a collection of state machines that interact with each other.

Naturally there is a preview of it in this part's source code, as an incentive. Like always.

Attached File  StateMachineTutorialsV4.zip (55.92KB)
downloads: 49




October 2014 »

S M T W T F S
   1234
567891011
12131415161718
19202122 23 2425
262728293031 

Recent Comments

PARTNERS