Hi there,
I’m working on a Custom Editor Window for a Rule-System. The Rule System itself is build from a bunch of classes (all Scriptable Objects) that are combined to make up a Rule. At the moment the whole System is build around 4 Classes and an Enum + 2 Editor-Classes, Code will be below.
I have the following Objects:
Ruleset:
[CreateAssetMenu(menuName = "Scriptable Objects/Ruleset")]
public class Ruleset : ScriptableObject
{
#region Variable Serialization
[SerializeField] List<Rule> rules = new List<Rule>();
#endregion
#region Properties
public List<Rule> Rules { get { return rules; } set { rules = value; } }
#endregion
#region Methods
#endregion
}
Rule:
public class Rule : ScriptableObject
{
[SerializeField] string ruleName = "Rule Name";
[SerializeField] List<Condition> conditions = new List<Condition>();
[SerializeField] Tip tip = null;
[SerializeField] List<TrackableAction> resetConditions = new List<TrackableAction>();
public string RuleName { get { return ruleName; } set { ruleName = value; } }
public List<Condition> Conditions { get { return conditions; } set { conditions = value; } }
public Tip Tip { get { return tip; } set { tip = value; } }
public List<TrackableAction> ResetConditions { get { return resetConditions; } set { resetConditions = value; } }
}
Condition:
[System.Serializable]
public class Condition : ScriptableObject
{
[SerializeField, Tooltip("Either one of these counts towards the Condition-Trigger Threshold")] List<TrackableAction> actions;
[SerializeField] bool trackAmount = true;
[SerializeField] int amount;
[SerializeField] float timeInSeconds;
public List<TrackableAction> Actions { get { return actions; } set { actions = value; } }
public bool TrackAmount { get { return trackAmount; } set { trackAmount = value; } }
public float TimeInSeconds { get { return timeInSeconds; } set { timeInSeconds = value; } }
public int Amount { get { return amount; } set { amount = value; } }
}
Tip:
public class Tip : ScriptableObject
{
[SerializeField] Sprite tipImage = null;
[SerializeField] LocalizedString tipText = null;
public Sprite TipImage { get { return tipImage; } set { tipImage = value; } }
public LocalizedString TipText { get { return tipText; } set { tipText = value; } }
}
And a simple enum with some testing values for now:
public enum TrackableAction
{
test, test2, test3
}
Now to the Tricky part. Since all of these are different Objects it can get pretty complicated to actually work with this System as is. So I have created a Custom Window that is used to combine all of these Objects into one View. At the moment I’m just prototyping around, so non of this is necessarily final Code and sometimes it shows, sorry for that.
Now in order to actually work with this, I’ve created a simple Custom Editor for the Ruleset Object that just throws away everything and show the User a Button to open the Window. It will also Setup the Window with the SerializedObject of the Ruleset that was used to open the Window.
RulesetEditor:
[CustomEditor(typeof(Ruleset))]
public class RulesetEditor : Editor
{
RulesetWindow window = null;
public override void OnInspectorGUI()
{
if(window == null && GUILayout.Button("Open Editor Window", GUILayout.Height(50f)))
{
window = RulesetWindow.Init();
window.Ruleset = serializedObject;
}
}
}
Now to my actual Problem, here is the long Code of the Window, I will get into details of what’s wrong with it below the Code:
public class RulesetWindow : EditorWindow
{
// Ruleset Reference for this Window
SerializedObject currentRuleset = null;
// The Current Size of the Window
Vector2 windowsSize = Vector2.zero;
GUIStyle headerTextStyle;
GUIStyle ruleNameStyle;
GUIStyle horizontalLine;
GUIStyle verticalLine;
Color headerBG = new Color(.1f, .1f, .1f, 1f);
Color rowEvenBG = new Color(.25f, .25f, .25f, 1f);
Color rowOddBG = new Color(.375f, .375f, .375f, 1f);
Color footerBG = new Color(.1f, .1f, .1f, 1f);
Color lightGrey = new Color(.5f, .5f, .5f, 1f);
Vector2 scrollPosition = Vector2.zero;
// Setter for the Ruleset Editor to call to set the Reference to the Ruleset that can be edited by this Window
public SerializedObject Ruleset { set { currentRuleset = value; } }
// Code to Initialize the Window
public static RulesetWindow Init()
{
RulesetWindow window = (RulesetWindow)GetWindow(typeof(RulesetWindow));
window.Show();
window.titleContent = new GUIContent("Edit Ruleset");
window.minSize = new Vector2(800, 600);
return window;
}
private void OnEnable()
{
// Setup HorizontalLine
horizontalLine = new GUIStyle();
horizontalLine.normal.background = EditorGUIUtility.whiteTexture;
horizontalLine.margin = new RectOffset(0, 0, 1, 1);
horizontalLine.fixedHeight = 2;
// Setup VerticalLine
verticalLine = new GUIStyle();
verticalLine.normal.background = EditorGUIUtility.whiteTexture;
verticalLine.margin = new RectOffset(1, 1, 0, 0);
verticalLine.stretchHeight = true;
verticalLine.fixedWidth = 2;
// Setup Header Style
headerTextStyle = new GUIStyle();
headerTextStyle.fontSize = 25;
headerTextStyle.fontStyle = FontStyle.Bold;
headerTextStyle.alignment = TextAnchor.MiddleCenter;
headerTextStyle.normal.textColor = Color.white;
// Setup Custom Fontstyle for Rulename Headers
ruleNameStyle = new GUIStyle();
ruleNameStyle.fontSize = 18;
ruleNameStyle.fontStyle = FontStyle.Bold;
ruleNameStyle.normal.textColor = Color.white;
}
// Used to set some Background Colors of the Window
GUIStyle SetBackground(Color c)
{
GUIStyle backgroundStyle = new GUIStyle();
Texture2D backtex = new Texture2D(1, 1);
backtex.SetPixel(0, 0, c);
backtex.Apply();
backgroundStyle.normal.background = backtex;
return backgroundStyle;
}
// Used to draw a Horizontal Line through the whole Window
void HorizontalLine(Color color)
{
Color c = GUI.color;
GUI.color = color;
GUILayout.Box(GUIContent.none, horizontalLine);
GUI.color = c;
}
// Used to draw a Vertical Line streched to the next Element
void VerticalLine(Color color)
{
Color c = GUI.color;
GUI.color = color;
GUILayout.Box(GUIContent.none, verticalLine);
GUI.color = c;
}
private void OnGUI()
{
// If Reference to Ruleset is not set, do nothing
if (currentRuleset == null)
return;
currentRuleset.Update();
windowsSize = position.size;
// Used to create the Names of new Scriptable Objects
StringBuilder nameBuilder = new StringBuilder();
// Used to delete a rule at the end of a frame so it can't break the for-loop
Rule ruleToDelete = null;
// Header for the Table
EditorGUILayout.BeginVertical(GUILayout.Height(40));
EditorGUILayout.BeginHorizontal(SetBackground(headerBG), GUILayout.Height(25));
// Placeholder for up/down Buttons
EditorGUILayout.BeginVertical(GUILayout.MinWidth(28));
GUILayout.Label("");
EditorGUILayout.EndVertical();
VerticalLine(lightGrey);
EditorGUILayout.BeginVertical(GUILayout.MaxWidth((windowsSize.x / 4 * 2) - 4 - 12));
GUILayout.Label("Condition", headerTextStyle);
EditorGUILayout.EndVertical();
VerticalLine(lightGrey);
EditorGUILayout.BeginVertical(GUILayout.MaxWidth((windowsSize.x / 6) - 34 - 6));
GUILayout.Label("Tip", headerTextStyle);
EditorGUILayout.EndVertical();
VerticalLine(lightGrey);
EditorGUILayout.BeginVertical(GUILayout.MaxWidth((windowsSize.x / 6 * 2) - 4));
GUILayout.Label("Reset Condition", headerTextStyle);
EditorGUILayout.EndVertical();
EditorGUILayout.EndHorizontal();
HorizontalLine(lightGrey);
// ScrollView will only encompass the Rule List, not the Table Header or the Buttons below
scrollPosition = EditorGUILayout.BeginScrollView(scrollPosition, false, true, GUILayout.Width(windowsSize.x), GUILayout.Height(windowsSize.y - 80f));
// Reference to the List<Rule> Parameter of the Ruleset
SerializedProperty ruleList = currentRuleset.FindProperty("rules");
if (ruleList.isArray)
{
// Iteration through every Rule
for (int i = 0; i < ruleList.arraySize; i++)
{
// Just to be sure, check if the List actually holds a Rule Object
if (ruleList.GetArrayElementAtIndex(i).objectReferenceValue is Rule)
{
Rule rule = ruleList.GetArrayElementAtIndex(i).objectReferenceValue as Rule;
// The Header of each Rule so they can be more easily distinguished
EditorGUILayout.BeginVertical();
GUILayout.Space(10);
EditorGUILayout.BeginHorizontal();
GUILayout.Space(5);
GUILayout.Label(rule.RuleName, ruleNameStyle, GUILayout.Height(20), GUILayout.MinWidth((windowsSize.x / 2) - 15));
rule.RuleName = EditorGUILayout.TextField(rule.RuleName, GUILayout.Height(20), GUILayout.MinWidth((windowsSize.x / 2) - 15));
EditorGUILayout.EndHorizontal();
GUILayout.Space(4);
HorizontalLine(lightGrey);
EditorGUILayout.EndVertical();
// Main Horizontal Group that holds all 3 parts of a Rule next to each other
// It will also change color, so each Rule can be distinguished further
// THIS HORIZONTAL ALSO SEEMS TO BE THE TROUBLE MAKER
EditorGUILayout.BeginHorizontal(i % 2 == 0 ? SetBackground(rowEvenBG) : SetBackground(rowOddBG));
// Create the Button Panel to the left of each Rule
EditorGUILayout.BeginVertical(GUILayout.MaxWidth(30), GUILayout.MinWidth(30));
// Move up Button
if (GUILayout.Button("\u2191"))
{
MoveListElementUp(i, (currentRuleset.targetObject as Ruleset).Rules);
}
// Delete Button
if (GUILayout.Button("X"))
{
ruleToDelete = rule;
}
// Move Down Button
if (GUILayout.Button("\u2193"))
{
MoveListElementDown(i, (currentRuleset.targetObject as Ruleset).Rules);
}
EditorGUILayout.EndVertical();
VerticalLine(lightGrey);
// Editor for Conditions
EditorGUILayout.BeginVertical(GUILayout.MaxWidth((windowsSize.x / 4 * 2) - 4));
for (int j = 0; j < rule.Conditions.Count; j++)
{
EditorGUILayout.BeginVertical();
EditorGUILayout.BeginHorizontal();
// List of Trackable Actions
SerializedObject conditionObject = new SerializedObject(rule.Conditions[j]);
SerializedProperty actionList = conditionObject.FindProperty("actions");
EditorGUILayout.PropertyField(actionList, true, GUILayout.MaxWidth((windowsSize.x / 4) * 1.5f));
conditionObject.ApplyModifiedProperties();
EditorGUILayout.BeginVertical();
// Condition type (time, amount ratio button + field to set amount)
int selection = rule.Conditions[j].TrackAmount ? 0 : 1;
int currentSelection = GUILayout.SelectionGrid(selection, new string[] { "amount", "time" }, 2, "toggle");
rule.Conditions[j].TrackAmount = currentSelection == 0 ? true : false;
if (rule.Conditions[j].TrackAmount)
rule.Conditions[j].Amount = EditorGUILayout.IntField(GUIContent.none, rule.Conditions[j].Amount);
else
rule.Conditions[j].TimeInSeconds = EditorGUILayout.FloatField(GUIContent.none, rule.Conditions[j].TimeInSeconds);
EditorGUILayout.EndVertical();
EditorGUILayout.EndHorizontal();
EditorGUILayout.BeginHorizontal();
// Move Up Button
if (GUILayout.Button("\u2191"))
{
MoveListElementUp(j, rule.Conditions);
}
// Remove Button
// Should probably be done the same way Rules get deleted to not risk breaking the for-loop
if (GUILayout.Button("X"))
{
DeleteCondition(rule.Conditions[j], true);
rule.Conditions.RemoveAt(j);
if (rule.Conditions.Count == 0)
CreateCondition(rule, 0);
// Reimport Asset so changes show up in Project-Window
AssetDatabase.ImportAsset(AssetDatabase.GetAssetPath(currentRuleset.targetObject));
}
// Move Down Button
if (GUILayout.Button("\u2193"))
{
MoveListElementDown(j, rule.Conditions);
}
EditorGUILayout.EndHorizontal();
if (j < rule.Conditions.Count)
HorizontalLine(new Color(.7f, .7f, .7f, 1f));
EditorGUILayout.EndVertical();
}
if (GUILayout.Button("New Condition"))
{
CreateCondition(rule, i);
}
EditorGUILayout.EndVertical();
VerticalLine(lightGrey);
// Tip
EditorGUILayout.BeginVertical(GUILayout.MaxWidth((windowsSize.x / 6) - 34));
// Because the Text is a LocalizedString from the I2.Loc package with a Custom PropertyDrawer
// this has to be rendered through a PropertyField
SerializedObject tipObject = new SerializedObject(rule.Tip);
SerializedProperty tipTextProp = tipObject.FindProperty("tipText");
GUILayout.Label("Tip Text");
EditorGUILayout.PropertyField(tipTextProp, GUIContent.none);
GUILayout.Space(5);
rule.Tip.TipImage = EditorGUILayout.ObjectField(rule.Tip.TipImage, typeof(Sprite), false, GUILayout.Width(100), GUILayout.Height(100)) as Sprite;
tipObject.ApplyModifiedProperties();
EditorGUILayout.EndVertical();
VerticalLine(lightGrey);
// Reset Condition
EditorGUILayout.BeginVertical(GUILayout.MaxWidth((windowsSize.x / 6 * 2) - 4));
// This is the ResetCondition List
// THIS LIST IS DISPLAYED WEIRD
SerializedObject ruleObject = new SerializedObject(rule);
SerializedProperty resetProp = ruleObject.FindProperty("resetConditions");
EditorGUILayout.PropertyField(resetProp, true, GUILayout.MaxWidth((windowsSize.x / 6 * 2) - 10));
ruleObject.ApplyModifiedProperties();
EditorGUILayout.EndVertical();
// END OF THE TROUBLE MAKER HORIZONTAL
EditorGUILayout.EndHorizontal();
}
HorizontalLine(lightGrey);
}
// Delete Rule if User pressed a delete Button
if (ruleToDelete != null)
{
int i = (currentRuleset.targetObject as Ruleset).Rules.IndexOf(ruleToDelete);
DeleteRule(ruleToDelete, true);
ruleList.DeleteArrayElementAtIndex(i);
}
currentRuleset.ApplyModifiedProperties();
}
EditorGUILayout.EndScrollView();
HorizontalLine(Color.white);
// Footer
EditorGUILayout.BeginVertical(SetBackground(footerBG));
if (GUILayout.Button("Create new Element"))
{
ruleList.arraySize++;
SerializedProperty newArrayElement = ruleList.GetArrayElementAtIndex(ruleList.arraySize - 1);
Rule newRule = CreateInstance<Rule>();
nameBuilder.Clear();
nameBuilder.Append("Rule ");
nameBuilder.Append(ruleList.arraySize - 1);
newRule.name = nameBuilder.ToString();
AssetDatabase.AddObjectToAsset(newRule, currentRuleset.targetObject);
CreateCondition(newRule, ruleList.arraySize - 1);
newRule.Tip = CreateInstance<Tip>();
nameBuilder.Append(" Tip");
newRule.Tip.name = nameBuilder.ToString();
AssetDatabase.AddObjectToAsset(newRule.Tip, currentRuleset.targetObject);
AssetDatabase.ImportAsset(AssetDatabase.GetAssetPath(currentRuleset.targetObject));
newArrayElement.objectReferenceValue = newRule;
currentRuleset.ApplyModifiedProperties();
currentRuleset.Update();
}
if (GUILayout.Button("Delete last"))
{
if (ruleList.GetArrayElementAtIndex(ruleList.arraySize - 1).objectReferenceValue is Rule)
{
Rule rule = ruleList.GetArrayElementAtIndex(ruleList.arraySize - 1).objectReferenceValue as Rule;
DeleteRule(rule, true);
ruleList.DeleteArrayElementAtIndex(ruleList.arraySize - 1);
currentRuleset.ApplyModifiedProperties();
}
}
EditorGUILayout.EndVertical();
EditorGUILayout.EndVertical();
}
// Used to move Objects up in their List
private void MoveListElementUp<T>(int index, List<T> list)
{
if (index == 0 || index >= list.Count)
return;
T save = list[index - 1];
list[index - 1] = list[index];
list[index] = save;
}
// Used to move Objects down in their List
private void MoveListElementDown<T>(int index, List<T> list)
{
if (index >= list.Count - 1)
return;
T save = list[index + 1];
list[index + 1] = list[index];
list[index] = save;
}
// Delete Rule and all children of it
private void DeleteRule(Rule ruleToDelete, bool reimportAsset = false)
{
for (int i = ruleToDelete.Conditions.Count - 1; i >= 0; i--)
{
DeleteCondition(ruleToDelete.Conditions[i]);
ruleToDelete.Conditions.RemoveAt(i);
}
if (ruleToDelete.Tip != null)
DeleteTip(ruleToDelete.Tip);
AssetDatabase.RemoveObjectFromAsset(ruleToDelete);
DestroyImmediate(ruleToDelete, true);
if (reimportAsset)
AssetDatabase.ImportAsset(AssetDatabase.GetAssetPath(currentRuleset.targetObject));
}
// Delete Condition
private void DeleteCondition(Condition conditionToDelete, bool reimportAsset = false)
{
AssetDatabase.RemoveObjectFromAsset(conditionToDelete);
DestroyImmediate(conditionToDelete, true);
if (reimportAsset)
AssetDatabase.ImportAsset(AssetDatabase.GetAssetPath(currentRuleset.targetObject));
}
// Delete Tip
private void DeleteTip(Tip tipToDelete, bool reimportAsset = false)
{
AssetDatabase.RemoveObjectFromAsset(tipToDelete);
DestroyImmediate(tipToDelete, true);
if (reimportAsset)
AssetDatabase.ImportAsset(AssetDatabase.GetAssetPath(currentRuleset.targetObject));
}
// Create new Condition, Name it and make it a SubAsset of the current Ruleset
private void CreateCondition(Rule rule, int index)
{
StringBuilder nameBuilder = new StringBuilder();
Condition newCondition = CreateInstance<Condition>();
nameBuilder.Append("Rule ");
nameBuilder.Append(index);
nameBuilder.Append(" Condition ");
nameBuilder.Append(rule.Conditions.Count);
newCondition.name = nameBuilder.ToString();
AssetDatabase.AddObjectToAsset(newCondition, currentRuleset.targetObject);
rule.Conditions.Add(newCondition);
AssetDatabase.ImportAsset(AssetDatabase.GetAssetPath(currentRuleset.targetObject));
}
}
So, I have multiple Problems with this:
First, the big one that actually drives me crazy: Items in the Reset Condition List are not rendered (Line 266 RulesetWindow). BUT, if I remove both Lines marked as trouble Makers (Lines 158 and 270) they are rendered and I have no Idea why that would be. Also note that the list of Condition Actions is always getting rendered. The code is nearly identical and both Lists have the same type.
Second: Items in a Condition List and the Reset Condition List cannot be changed (Lines 192 and 266 if they are rendered). This is probably not to bad, I may have forgotten something.
Some Screenshots so you know what it all looks like:
Window with all layout functions enabled, but without ResetCondition-Elements being rendered (you can see the Element of each, but there is no Enum-Dropdown)
Window with the Trouble Maker Lines removed. Marked Item is the same as aboth and the dropdown is displayed, Layout of course is completly off in this one
Also some weird behaviour I have observed: When I click the Title of either a Condition Action List or a Reset Condition List, all of them in each Rule will be collapsed or expanded:
I know this is a big one and very specific. I’ve put a bunch of time into this and so far have not spotted anything that could explain this weird behaviour. I would be thankful for any help.