An Easy Framework for Component Data Customization and Testing
1915 words
9 minutes
Contents
Some times in our games we might have multiple objects with a multitude of data for which we’d like to have multiple files with different configurations for our component in order to find the best/most balanced/.. configuration. For the sake of explaining let’s use the Abilities and Attributes tab from Shadows of Mordor or the Perk trees of The Elder Scrolls V : Skyrim.
Abilities Menu in Shadows of Mordor
Attributes List in Shadows of Mordor
Perks Menu in The Elder Scrolls V : Skyrim
In the Abilities list of Shadows of Mordor we have a graph structure with each node unlocking an ability for us to use. But as you can see we have a specific layout when it comes to how things are positioned which has a direct effect on the graph’s nodes and especially the graph’s edges which show how the nodes are connected to each other and upon which nodes a given node depends. Of course one can see it as a two dimensional array but in the case of the Perks Menu in Skyrim we do not have such an easy representation codewise.
So when the designers of the game sat down to create that list they might have made some corrections/changes/insertions along the way. Of course in major developers like Monolith Productions or Bethesda Softworks, the designers might have already devised the abilities themselves and laid them out beforehand in the Game Design Document, way before it hits the Art or the Engineering team.
However, in smaller studios there might not be such a process. So you want to create editor tools for your designers to start creating and testing things, or even make corrections/changes/insertions later on, in the development stage.
A good idea is to be able to support configuration files.
At this point, the project I’m working on doesn’t have a save/load system and it will be a while before we can do that, especially since not all systems are ready. So a way to do these things with very little fuss , and gradually supporting a potential Save/Load system, is to write an Editor for the component type and save it’s configuration to an asset file using the AssetDatabase.
Visual example of asset files being used
Asset files are good when it comes to Editor-RuntimeComponent communication since they are supported by the UnityEngine for drag and dropping on a GUI Object Field. Thus the easy testing of the configurations. Basically Asset files in our case are ScriptableObjects(or their derivations) that have been serialized into files by the UnityEngine. Another good point is that since they are serialized by the engine itself, you can “inspect” the contents of the asset files and also edit them. Word to the wise, if you’re going to create a custom editor for the component, you’d probably leave the editing to the custom editor since your manual edits might break something where your custom editor will have checks.
Another good idea is to bundle up data in classes or structs(whichever is good in your situation) per their usage. You might have data you only need in your component’s editor class, or data just for the runtime, or data just for the frontend of your component(In Shadows of Mordor we could have a structure for the frontend containing Row, Column, Texture info for a 5x10 “array” in order to render the texture of a specific ability in a specific cell of the “array”, or at least its a possible solution). If you keep your data in such bundles, you can separate your runtime data, data that can change and that will need to be saved when you implement your Save/Load system. They can also be loaded back very easily. If both your asset file and your savegame files share the same structure, your component can use the same functions to load from them.
publicclassStatisticsController:Monobehaviour{privateStatisticsDatadata;//We use this in an initialization function to grab the initial data//when we start a new game.If we want to load a game we leave this//unless we have other kinds of info stored that don't change//between savegame files. [SerializeField]publicStatisticsTemplate_configuration;//Using start for the example but you do this wherever makes sensevoidStart(){//.....other initialization commandsif(GameMode.NEWGAME){//Copy over data from _configuration to data}elseif(GameMode.LOADGAME){//copy data from the save game file to data}//....other initialization commands}...}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//This is what is serialized in the asset form.[System.Serializable]publicclassStatisticsTemplate:ScriptableObject{ [SerializeField]publicStatisticsDataruntimeData; [SerializeField]publicStatisticsEditorDataeditorData;publicvoidOnEnable(){if(runtimeData==null){runtimeData=newStatisticsData();}if(editorData==null){editorData=newStatisticsEditorData();}}}
1
2
3
4
5
6
7
8
9
10
11
12
//Runtime Data//Having them bundled this way is good because we can use this//structure both for our editor template and for when we want to//serialize or deserialize them from a save game files.[System.Serializable]publicclassStatisticsData{ [SerializeField]publicintA; [SerializeField]publicint[]B;}
1
2
3
4
5
6
7
8
9
10
//Data used for reloading the component editor's state in order//to resume editing seamlessly.[System.Serializable]publicclassStatisticsEditorData{ [SerializeField]publicintC; [SerializeField]publicList<string>D;}
None of the above are functional though without using custom editors in order to create the tools with which your designers will use to test out things. A custom editor using asset files has some responsibilities, like always have an asset file being used even if that means creating a new one. So if we have none, create one. If we have one assigned to the object field, try to load it. Check for null where you have to, call new where you have to etc.
I’ve found the following template of code works for me quite well:
[Serializable][CustomEditor(typeof(StatisticsController))]publicclassStatisticsControllerEditor:Editor{privateconststringstatisticsPath="Assets/Resources/Statistics/";privateStatisticsControllerself=null;publicvoidOnEnable(){self=(StatisticsController)target;if(self._configuration==null){stringassetPathAndName=statisticsPath+"/"+typeof(StatisticsTemplate).ToString()+"_New.asset";self._configuration=(StatisticsTemplate)(AssetDatabase.LoadAssetAtPath(assetPathAndName,typeof(StatisticsTemplate)));if(self._configuration==null){self._configuration=CreateInstance<StatisticsTemplate>();AssetDatabase.CreateAsset(self._configuration,assetPathAndName);AssetDatabase.SaveAssets();}}}publicoverridevoidOnInspectorGUI(){//important:remember to set allowsceneobjects to false since we//only want files from the project hierarchyself._configuration=(StatisticsTemplate)(EditorGUILayout.ObjectField("Statistics Template:",self._configuration,typeof(StatisticsTemplate),false));if(GUILayout.Button("Open Template Editor")){//Instead of an Undo functionality we pass two save function//delegates here because we would like the window to have its own//context in case we'd want to scrap the progress we've made but//still preserve the already saved work.Also we can backup the//asset files for good measure,which is another nice thing to have.StatisticsEditoredWin=newStatisticsEditor();edWin.ShowWindow(self._configuration,Save,SaveAs);}if(GUI.changed)EditorUtility.SetDirty(self);}privatevoidSave(intA,int[]B,intC,List<string>D){self._configuration.editorData.C=C;self._configuration.editorData.D=newList<string>(D);self._configuration.runtimeData.A=A;self._configuration.runtimeData.B=B;EditorUtility.SetDirty(self._configuration);AssetDatabase.SaveAssets();EditorUtility.SetDirty(self);}privatevoidSaveAs(intA,int[]B,intC,List<string>D){stringassetPath=EditorUtility.SaveFilePanel("Save statistics scheme as...",statisticsPath+"/","StatisticsTemplate_"+self.name+".asset","asset");assetPath=assetPath.Remove(0,assetPath.LastIndexOf(statisticsPath));if(assetPath!=null||assetPath!=""){//Saving previously handled file to be sureAssetDatabase.SaveAssets();//Creating a new oneself._configuration=newStatisticsTemplate();AssetDatabase.CreateAsset(self._configuration,assetPath);self._configuration.editorData.C=C;self._configuration.editorData.D=newList<string>(D);self._configuration.runtimeData.A=A;self._configuration.runtimeData.B=B;EditorUtility.SetDirty(self._configuration);AssetDatabase.SaveAssets();EditorUtility.SetDirty(self);}}}
and the Editor Window where all the editing magic happens:
publicdelegatevoidStatisticsEditorSaveDelegate(intA,int[]B,intC,List<string>D);[Serializable]publicclassStatisticsEditor:EditorWindow{#regionWindowSpecificsprivateVector2scrollPos;#endregion#regionDataStructuresprivateintA;privateint[]B;privateintC;privateList<string>D;#endregion#regionEventSystempubliceventStatisticsEditorSaveDelegatesaveClicked;publiceventStatisticsEditorSaveDelegatesaveAsClicked;#endregionpublicvoidShowWindow(StatisticsTemplatetemplate,StatisticsEditorSaveDelegatesave,StatisticsEditorSaveDelegatesaveAs){#regionRegisterEventssaveClicked+=save;saveAsClicked+=saveAs;#endregion#regionLoadData//A simplistic way of sanity check to see if this is a newly//constructed templateif(template.runtimeData.D==null){C=0;D=newList<string>();A=0;B=newint[];}//else we have data to pull from the templateelse{C=template.editorData.C;D=newList<string>(template.editorData.D);A=template.runtimeData.A;B=template.runtimeData.B;}#endregion#regionInstatiateWindowStatisticsEditorinstanceWindow=(StatisticsEditor)EditorWindow.GetWindow(typeof(StatisticsEditor));#endregion}publicvoidOnDestroy(){//Usually clear lists and dictionaries here for good measureD.Clear();D=null;}publicvoidOnGUI(){scrollPos=EditorGUILayout.BeginScrollView(scrollPos);GUILayout.Space(10);//Following is the bar that holds the save and saveAs//buttons which ShowWindow has hooked with the//StatisticsEditors Save and SaveAs functions that actually//do the saving.So each time you open the editor window//and edit you have to click on either of these to actually//save the work.GUILayout.BeginHorizontal(EditorStyles.toolbar);if(GUILayout.Button("Save",EditorStyles.toolbarButton)){//Execute Save logic hereOnSave();}if(GUILayout.Button("Save As..",EditorStyles.toolbarButton)){OnSaveAs();}GUILayout.FlexibleSpace();GUILayout.EndHorizontal();//...Here you put the input fields using EditorGUILayout APIif(GUI.changed){//Here we don't call SetDirty as it defeats the purpose//of hooking up save delegates from the StatisticsEditor//,so just repaint when changes occur.Repaint();}EditorGUILayout.EndScrollView();}}
Now you can create as many asset files you need with different configurations, change them very fast and compare between the results.
Note
This way of doing things works only for PODs(Plain Old Data). It won’t work with references of scene objects(any Monobehaviour). It might work at first, while you are in the same session of the Unity engine(basically uses InstanceIDs to keep references), but as soon as you restart the editor the references will be gone leaving just the rest of the data and a broken asset file for you. In the case that you want to store references you’d better use the engine’s serialization system by using SerializedObjects and SerializedProperties. Of course if you’d like to change this reference based on the configuration file you’ll have to do it manually or automate it through the editor somehow.