Battletech Developer Journal - 08

Published June 30, 2019
Advertisement

I'm Chris Eck, and I'm the tools developer at HBS for the Battletech project. I've recently been given permission to write up articles about some of the things I work on which I hope to post on a semi regular basis. Feel free to ask questions about these posts or give me suggestions for future topics. However please note, I am unable to answer any questions about new/unconfirmed features.

We shipped Urban Warfare and the launch was relatively smooth. There were only a few critical bugs we needed to patch out and we did so in a timely manner. After that, the team focused on the upgrade to Unity 2018. The day after most of the team was upgraded and many of the kinks were worked out... Unity released a new 2018 version. >.< Still there's lots of cool features and editor performance improvements that I'm looking forward to.

Data Driven Enumerations

With the team focused on the Unity upgrade process, things were settled down enough for me to finally cram my Enum replacement into the project. It's something I've been wanting to do since last year. I was hoping to get it in during the 1.4 release in December, but some DataManager optimization refactors took priority and caused some stability issues which made testing my code impossible.

Why replace enums? Aren't they a good thing? - At first glance, it does seem like enums are great. It's a simple type you can pass around and it gives your code clarity over using an integer or string. Plus it's built into the language so it takes very little effort to implement. In very simple cases, I think they still have their use. But the second you start tying logic to individual values, I think you should strongly consider a data driven approach.

I decided to tackle ContractType as my first enum in our code base to replace. It is probably the most complex enum in our game: it touches the Sim Game, the Combat Game, Content Creation, Serialization, and several other systems. If my Dynamic Enum can replace this guy, then it's safe to use it for everything else in our system.

Requirements

1. Easy to use in Unity - In Unity enums are drawn as drop down controls and I don't want to lose that.
2. Support int or string as key - I want to be able to use either of these types. Integers if performance is a big deal or strings to keep things human readable.
3. Don't break save games - There needs to be some kind of upgrade path for loading old saves.
4. One place to edit the data - There were at least a dozen places you had to go to add a new Contract Type. I want exactly one place. And I want it in a format that a developer (specifically me) doesn't have to get involved.

Getting Unity to draw my data like a drop down was pretty easy. I found an excellent code sample for something similar here: https://gist.github.com/ProGM/9cb9ae1f7c8c2a4bd3873e4df14a6687 So I created my own DynamicEnum attribute and wrote a PropertyDrawer for it. And that knocked out the first two requirements. Here's what the code looks like:

Spoiler

 



/// <summary>
/// This is an attribute used to mark an integer or string to be drawn as a drop down menu
/// by the DynamicEnumPropertyDrawer.
/// 
/// Based off of code found here: https://gist.github.com/ProGM/9cb9ae1f7c8c2a4bd3873e4df14a6687
/// </summary>
public class DynamicEnumAttribute : PropertyAttribute
{
	/// <summary>
	/// 
	/// </summary>
	/// <param name="enumType">string of the value stored in EnumValue_MDD.EnumType</param>
	public DynamicEnumAttribute(string enumType)
	{
		MetadataDatabase mdd = MetadataDatabase.Instance;
		{
			NameToIntDict = new Dictionary<string, int>();
			IntToNameDict = new Dictionary<int, string>();
			List<string> nameList = new List<string>();
			List<int> intList = new List<int>();

			// Get a list of all the enumeration values
			List<EnumValue_MDD> enumValueList = mdd.GetEnumValueListByType(enumType);
			foreach (EnumValue_MDD enumValue in enumValueList)
			{
				// Setup the lists
				nameList.Add(enumValue.Name);
				intList.Add((int)enumValue.EnumValueID);

				// Setup the dictionaries
				NameToIntDict[enumValue.Name] = (int)enumValue.EnumValueID;
				IntToNameDict[(int)enumValue.EnumValueID] = enumValue.Name;
			}

			NameList = nameList.ToArray();
			IntList = intList.ToArray();
		}
	}

	/// <summary>
	/// Dictionary of EnumValue_MDD.Name to EnumValue_MDD.EnumValueID
	/// </summary>
	public Dictionary<string, int> NameToIntDict
	{
		get;
		private set;
	}

	/// <summary>
	/// Dictionary of EnumValue_MDD.EnumValueID to EnumValue_MDD.Name
	/// </summary>
	public Dictionary<int, string> IntToNameDict
	{
		get;
		private set;
	}


	/// <summary>
	/// List of EnumValue_MDD.Name
	/// </summary>
	public string[] NameList
	{
		get;
		private set;
	}

	/// <summary>
	/// List of EnumValue_MDD.EnumValueID
	/// </summary>
	public int[] IntList
	{
		get;
		private set;
	}
}

/// <summary>
/// This property drawer draws integers or strings marked as DynamicEnums as a drop down menu 
/// in the inspector.
/// </summary>
[CustomPropertyDrawer(typeof(DynamicEnumAttribute))]
public class DynamicEnumPropertyDrawer : PropertyDrawer
{
	public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
	{
		// Pull the dynamic enum data
		DynamicEnumAttribute dynamicEnumAttribute = attribute as DynamicEnumAttribute;
		string[] nameList = dynamicEnumAttribute.NameList;
		int[] intList = dynamicEnumAttribute.IntList;

		// If the field is a string, store the string value
		if (property.propertyType == SerializedPropertyType.String)
		{
			// Find what index we are by doing a case insensitve compare. 
			int index = Array.FindIndex(nameList, t => t.Equals(property.stringValue, StringComparison.InvariantCultureIgnoreCase));
            Mathf.Max(0, index);

			// Draw the popup and collect the index, then store the text of the name.
			index = EditorGUI.Popup(position, property.displayName, index, nameList);
			property.stringValue = nameList[index];
		}
		// If the field is an integer, store the int value
		else if (property.propertyType == SerializedPropertyType.Integer)
		{
			// Find what index we are by finding our id in the list
			property.intValue = EditorGUI.Popup(position, property.displayName, property.intValue, nameList);
			int index = Mathf.Max(0, Array.IndexOf(intList, property.intValue));

			// Draw the popup and collect the new index, then store the id
			index = EditorGUI.Popup(position, property.displayName, index, nameList);
			property.intValue = intList[index];
		}
		// Otherwise not sure what to do with this field. Just draw it like normal I suppose.
		else
		{
			base.OnGUI(position, property, label);
		}
	}
}

 

To make sure old save games were supported, I created a new constant for ContractType called INVALID_DEFAULT. Any place that stored the old ContractType now got a new ContractTypeID field which defaults to INVALID_DEFAULT_ID. Since this is a new field, old saves wouldn't have it yet. After deserializing these classes, we just check to see if the new field is the INVALID_DEFAULT_ID and if it is, we convert the old Enum value to the integer ID. Any new content created, would serialize correct values while old save games get upgraded to the new way of storing it. I also protected the ID and wrote a property to make sure we were always pulling the correct data.


[DynamicEnum(ContractTypeEnumeration.TypeName)]
// This is protected so people are forced to use the property.
protected int supportedContractTypeID = ContractTypeEnumeration.INVALID_DEFAULT_ID;

private ContractType_MDD supportedContractTypeRow = null;
public ContractType_MDD SupportedContractTypeRow
{
	get
	{
		// If we're loading from an old version, convert the old value to the new value.
		if (supportedContractTypeID == ContractTypeEnumeration.INVALID_DEFAULT_ID)
		{
			supportedContractTypeID = (int)supportedContractType;
		}

		// If the cached row is null or out of date, pull the correct one from the database.
		if (supportedContractTypeRow == null || supportedContractTypeRow.ContractTypeID != supportedContractTypeID)
		{
			supportedContractTypeRow = MetadataDatabase.Instance.GetContractTypeByContrctTypeID(supportedContractTypeID);
		}

		return supportedContractTypeRow;
	}
}

Number four took the longest even if it wasn't the toughest. The concept is simple. Create a class that represents one line item in the original enumeration like ContractType.SimpleBattle. As you identify different code decisions based on individual enumeration values, create fields to store that data. My class cor ContractType wound up looking like this:


public class ContractTypeValue : EnumValue
{
	public ContractTypeValue() : base()
	{
	}

	public ContractTypeValue(EnumValue_MDD enumValueRow, ContractType_MDD contractTypeRow) : base (enumValueRow)
	{
		Version = (int)contractTypeRow.Version;
		IsSinglePlayerProcedural = contractTypeRow.IsSinglePlayerProcedural;
		IsStory = contractTypeRow.IsStory;
		IsRestoration = contractTypeRow.IsRestoration;
		CustomMusic = contractTypeRow.CustomMusic;
		IsMultiplayer = contractTypeRow.IsMultiplayer;
		UsesFury = contractTypeRow.UsesFury;
		ContractRewardMultiplier = contractTypeRow.ContractRewardMultiplier;
		Illustration = contractTypeRow.Illustration;
		Icon = contractTypeRow.Icon;
	}


	public int Version { get; private set; }

	public bool IsSinglePlayerProcedural { get; private set; }
	public bool IsStory { get; private set; }
	public bool IsRestoration { get; private set; }
	public string CustomMusic { get; private set; }

	public bool IsMultiplayer { get; private set; }
	public bool UsesFury { get; private set; }

	public float ContractRewardMultiplier { get; private set; }
	public string Illustration { get; private set; }
	public string Icon { get; private set; }


	public bool IsStoryOrRestoration { get { return IsStory || IsRestoration; } }
}

// Old code looks like this
bool usesFury = (Combat.EncounterLayerData.SupportedContractType == ContractType.ArenaSkirmish);

// New code looks like this
bool useFury = Combat.EncounterLayerData.SupportedContractTypeRow.UsesFury;

// It's subtle, but makes a big difference. Before we had one hardcoded type that used fury
// to change our mind about that we'd have to touch several places in the code where this
// fact was coded (UI, game logic, resource loading, etc). If we wanted to add a new contract
// type that used fury we'd have to add that with code. With the new system, we just flip
// a bit from false to true and now that ContractType uses fury instead.

I now have a single json file that contains a list of all the contract types and their associated metadata. Instead of having decisions hard-coded to individual enumeration values, we drive our decisions off the data. This gives us exactly one spot to edit the data and it means designers (and modders too!) can add a new ContractType without getting engineers involved. 

Before, this was a process that took a while to get right because we would forget ALL the places that ContractType values were being used to drive logic. All in all this refactor touched 88 files and had dozens of different places with hardcoded logic. A big change like this reduces complexity while increasing flexibility which are two big steps in the right direction.

Personal Project Update - Car Wars

I've wanted a computerized version of Car Wars since high school (which was a few years ago now). Since then, I've started my own version several times but never really made it anywhere of consequence. I have the itch to start it again so I've been working out some decisions on paper and chipping away at things the last few weeks. There's not much to show off yet, but I wanted to announce it as a self-motivational thing. Now that YOU know I'm working on it, I have to KEEP working on it. Otherwise, I'll be letting you down. :)

Tips From your Uncle Eck

Be careful with your use of enums, they can be a code smell that something isn't quite right. If you find yourself hardcoding logic to specific enum values, you should definitely consider switching to a data driven system. That way when people change their minds, they can just change the data in a file instead of having to change code and cut a new build. Plus it makes modding your game that much easier.


Links

2 likes 2 comments

Comments

unclecid

informative and enlightening as always Eck

ok so with the update to unity 2018 does that mean an engine update to the game itself is coming?

or was that just a dev side kind of update?

and when you enum replacement hits for contract type will that break custom contract players have made for side mission and FPs?

 

June 30, 2019 03:52 PM
Eck

We've been on Unity 5.6 for a loooong time. Mainly because that's what we shipped on. With a lull in the action it's a good time to upgrade. I know for sure there are development side improvements like editor speed/memory/optimizations. As for what features the players will see - I'm not sure. I haven't been following the upgrade discussions much. I've been laser focused on this coding straight for 8+ hours a day. :)

This will almost definitely break mods that had custom contract types, depending on how they implemented it. However, I think the new system will be simple to upgrade to. Basically they'll just need to put a new row in the StreaminAssets/data/enums/ContractType.json and process that file into the MDDB. If there are any serialization issues with save games, they'll have to code up a similar "upgrade path" as I spoke about above. But all of that is relatively simple so they shouldn't be broken for long.

Plus I'm on a few Battletech modding discords and will answer questions if anyone runs into some serious issues.

June 30, 2019 04:06 PM
You must log in to join the conversation.
Don't have a GameDev.net account? Sign up!
Advertisement