Dies ist der zweite Teil des Forschungsbaum-Editor Tutorials. Der vollständige Quellcode des Tutorials kann hier heruntergeladen werden:
Im ersten Teil des Tutorials wurden die Grundlagen für den Forschungsbaum-Editor gelegt. Damit ist es bereits möglich, die Datenstruktur des Forschungsbaums im Code abzubilden, sowie Fenster für einzelne Forschungsknoten darzustellen. Im Folgenden soll nun erläutert werden, wie Fenster mit UI-Elementen versehen, Abhängigkeiten zwischen den Forschungsknoten visualisiert, und Änderungen am Forschungsbaum gespeichert und geladen werden können.
Editor-GUI Grundlagen
Bislang wurden im Rahmen des Tutorials UI-Elemente nur exemplarisch in Form von Buttons verwendet. Diese wurden mittels GUILayout.Button instanziert. Wie genau aber ist das UI-System aufgebaut und wie wird es genutzt?
Der Unity-Editor verwendet intern das sog. Immediate Mode GUI (IMGUI). Dieses System wurde ursprünglich sowohl für den Editor als auch für GUIs in den Spielen selbst verwendet. Diese Zeiten sind bekanntlich lange vorbei. Inzwischen wird für die Spiele-GUIs ein GameObject-basiertes System verwendet. Das IMGUI hat sich für Editor-Tools jedoch bis heute gehalten, was zwar die Gestaltungsmöglichkeiten und Performance ein wenig eingrenzt, die Tools jedoch selbst mit sehr alten Unity-Version abwärtskompatibel macht.
Das IMGUI basiert darauf, dass das gesamte UI für jeden Frame neu gezeichnet wird. UI-Elemente können ausschließlich via Code definiert werden, was in der OnGUI-Methode erfolgt. Diese wird periodisch ähnlich der Update-Methode in Spielen aufgerufen, wobei bei jedem Aufruf das vorherige UI verworfen und durch ein neues ersetzt wird. UI-Elemente werden hierbei von 3 Klassen bereitgestellt: GUI, GUILayout und EditorGUILayout. Schaut man sich diese Klassen etwas genauer an, stellt man schnell fest, dass sich ihre Funktion sehr ähnelt und sie teilweise nahezu identische UI-Elemente bereitstellen. Wofür also 3 Klassen? Grob kann man diese folgendermaßen unterscheiden:
GUI: GUI-Elemente, deren Position und Layout direkt und frei festgelegt werden kann, i.d.R. als Rect. Unbeeinflusst von zuvor festgelegten Layout-Deklarationen.
GUILayout: GUI-Elemente, deren Layout automatisch anhand zuvor festgelegter Layout-Deklarationen bestimmt wird. Einzelne Layout-Parameter können im Bedarfsfall angepasst oder überschrieben werden.
EditorGUILayout: Standartisierte GUI-Elemente, die für Editor-Tools mit Unity-ähnlichem look-and-feel verwendet werden können. Da IMGUI zuvor auch für Spiele-GUIs verwendet wurde, war die Abspaltung einiger UI-Elemente in diese Klasse notwendig, da diese UI-Elemente nur für den Editor, nicht jedoch im Spiel verfügbar sind, z.B. Color-Picker. Heute ist diese Unterscheidung nicht mehr von Bedeutung.
Welche Klasse man für seine eigenen Editor-Tools verwendet ist reine Geschmackssache. Auch können diese problemlos vermischt und gleichzeitig im selben Tool verwendet werden.
Labels und Textboxen
Labels und Textboxen lassen sich ähnliche wie Buttons sehr einfach über das GUI-System erzeugen. Die folgenden Beispiele konzentrieren sich hierbei auf UI-Elemente, die mittels GUILayout erzeugt werden und sich somit nach zuvor definiertem Layout richten. Ein Label kann einfach mit einem Text erzeugt werden, jedoch lassen sich einzelne Layout-Eigenschaften über GUILayoutOption-params anpassen.
GUILayout.Label("Label with complete auto-Layout");
GUILayout.Label("Label with fixed Width", GUILayout.Width(200));
GUILayout.Label("Label with minimum necessary Size", GUILayout.ExpandWidth(false));
Zusätzlich werden wir Textboxen zur Eingabe von Text und Zahlen benötigen. Praktischerweise bietet Unity über die EditorGUILayout-Klasse eine reichhaltige Auswahl von vorgefertigten UI-Elementen, die bereits eine passende Validierung der Eingaben bereitstellen. Mit diesen kann sehr einfach sichergestellt werden, dass nur valide Werte vom Benutzer eingegeben werden können, was wiederum viel Arbeit beim Implementieren und Debugging erspart.
text = EditorGUILayout.TextField(text); //Textfield to enter any Text
number = EditorGUILayout.IntField(number); //Textfield to enter Digits only
Editierbare Forschungsknoten
Der bislang implementierte Code kann für jeden Forschungsknoten unserer Testdaten ein einzelnes Fenster anzeigen. Jedoch kann man weder deren Namen, noch die Forschungskosten bearbeiten. Daher fügen wir unserer DrawResearchNodeWindow-Methode nun einige UI-Elemente hinzu. Zunächst wird eine vertikale LayoutGroup definiert, die dazu führt, dass alle folgenden GUILayout-Elemente vertikal gestapelt dargestellt werden. Anschließend werden Labels mit Überschriften, sowie Textboxen zur Eingabe von Werten definiert. Ein GUILayout.Space erzeugt eine kleine Spalte zwischen UI-Elementen und dient der besseren Darstellung.
/// <summary>
/// This Method defines UI-Elements within the Window.
/// </summary>
/// <param name="id">The Windows unique ID, which equals the ResearchNodes ID.</param>
private void DrawResearchNodeWindow(int id)
{
//Get Array Index by ResearchNode ID
int index = researchNodes.FindIndex(x => x.researchNodeID == id);
//Define all UI-Elements for a single Research Node, stacked vertically
GUILayout.BeginVertical();
{
//Nodes Name Edit-Fields
GUILayout.Label("Name:", GUILayout.Width(220));
researchNodes[index].researchNodeName = EditorGUILayout.TextField(researchNodes[index].researchNodeName, GUILayout.Width(220));
GUILayout.Space(10);
//Nodes ResearchPoints Edit-Fields
GUILayout.Label("Research Points:", GUILayout.Width(220));
researchNodes[index].researchNodeCost = EditorGUILayout.IntField(researchNodes[index].researchNodeCost, GUILayout.Width(220));
}
GUILayout.EndVertical();
//Set the Window as dragable
GUI.DragWindow();
}
Das Forschungsknoten-Fenster enthält mit dieser kleinen Erweiterung jetzt schon alles Notwendige, um die Testdaten anzuzeigen und zu bearbeiten. Natürlich sind Änderungen noch nicht persistent, aber darum kümmern wir uns später. An dieser Stelle können natürlich beliebige zusätzliche Felder für den Forschungsknoten definiert und mittels UI-Elementen editierbar gemacht werden, je nach persönlicher Anforderung.
Verknüpfungen visualisieren
Ein wichtiger Teil eines Forschungsbaumes haben wir zwar bislang in den Testdaten definiert, aber noch nicht visualisiert: die Verknüpfungen zwischen einzelnen Forschungsknoten. Eine sinnvolle Methode diese Verknüpfungen darzustellen wäre, (gekrümmte) Linien zwischen den Forschungsknoten-Fensterm einzuzeichnen. Hierfür ergänzen wir den Code um eine neue Methode. Diese nimmt zwei Fenster als Rect-Objekte, d.h. deren Position und Größe, entgegen und zeichnet eine Linie zwischen der Oberseite des Start-Fensters und der Unterseite des End-Fensters ein. Als kleiner grafischer Effekt werden gleich mehrere Linien unterschiedlicher Dicke und Deckkraft eingezeichnet, um einen Leucht- und Schatten-Effekt zu simulieren.
/// <summary>
/// Calculates a Line between two Windows and draws it.
/// The Line starts at the Top of the Start-Window and ends at the Bottom of the End-Window.
/// </summary>
/// <param name="start">The Start-Window Rectangle.</param>
/// <param name="end">The End-Window Rectangle.</param>
private void DrawLineBetweenWindows(Rect start, Rect end)
{
//Calculate Line Parameters
Vector3 startPos = new Vector3(start.x + start.width / 2.0f, start.y, 0.0f);
Vector3 endPos = new Vector3(end.x + end.width / 2.0f, end.y + end.height, 0.0f);
Vector3 startTan = startPos + Vector3.down * 50.0f;
Vector3 endTan = endPos + Vector3.up * 50.0f;
Color shadowCol = new Color(0.0f, 1.0f, 1.0f, 0.06f);
//Draw Line Shadows
for (int i = 0; i < 3; i++)
{
Handles.DrawBezier(startPos, endPos, startTan, endTan, shadowCol, null, (i + 1) * 10.0f);
}
//Draw Lines
Handles.DrawBezier(startPos, endPos, startTan, endTan, Color.cyan, null, 4.0f);
}
Anschließend wird die OnGUI-Methode ergänzt. Der unten gezeigte Code muss vor dem BeginWindows-Element, d.h. ganz zu Beginn der Methode, eingefügt werden. Dieser durchsucht nun alle Forschungsknoten nach Verknüpfungen zu anderen Forschungsknoten und zeichnet eine Linie zwischen diesen.
/// <summary>
/// This Method is called whenever the UI is updated.
/// </summary>
void OnGUI()
{
//Draw Lines between Nodes
for (int i = 0; i < researchNodes.Count; i++)
{
//Draw a Line for each Relation
for (int j = 0; j < researchNodes[i].researchNodeUnlockedResearchNodes.Count; j++)
{
Rect startWindow = windowPositionAndSizes[i];
Rect targetWindow = windowPositionAndSizes[researchNodes.IndexOf(researchNodes[i].researchNodeUnlockedResearchNodes[j])];
DrawLineBetweenWindows(startWindow, targetWindow);
}
}
...
}
Das Ergebnis ist diese einfache, aber sehr funktionale Darstellung der Abhängigkeiten zwischen den Forschungsknoten. Natürlich muss man die Fenster nach Start des Editors noch manuell in Position ziehen, um diese übersichtliche Ansicht zu erhalten, aber auch eine persistente Speicherung der Position der einzelnen Fenster wird später noch implementiert.
Forschungsbaum als JSON speichern und laden
Nachdem die Forschungsknoten jetzt frei editiert werden können, sollte man seine Arbeit auch speichern und anschließend wieder laden können. Dies kann auf verschiedenste Weise umgesetzt werden, eine der einfachsten Methoden ist jedoch die persistente Speicherung im JSON-Format. Hierfür laden wir zunächst über Unitys AssetStore das JSON.Net Project for Unity herunter und importieren es über den PackageManager in das Projekt.
Anschließend können wir mit dieser Library einen einfachen Serialisierer zum Speichern und Laden der Forschungsknoten erzeugen. Hierzu wird eine neue generische Klassen namens JsonParser erstellt, die zwei Methoden beinhaltet, eine zum Speichern und eine zum Laden von Containern. Die Klasse wird bewusst generisch gehalten, um sich nicht auf eine Form von Containern festlegen zu müssen.
Die Save-Methode erhält hierbei den zu serialisierenden Container, sowie einen Dateinamen (inkl. Pfad und Dateiendung), unter der die Datei schlussendlich abgelegt wird. Wichtig ist beim Serialisierer die korrekte Konfiguration. So wollen wir einerseits, dass der erzeugte JSON formatiert und eingerückt ist, um besser lesbar zu sein – zum Debugging und evtl. vorkommendem manuellen Bearbeiten sehr nützlich. Weiterhin sollen auch Felder mit null-Werten serialisiert werden, damit das serialisierte Objekt einheitlicher wird. Zuletzt sollen Referenzen zwischen Objekten korrekt im JSON abgebildet werden. Letzteres ist insofern wichtig, als dass Forschungsknoten andere Forschungsknoten referenzieren und beim späteren Deserialisieren nicht ungewollt Kopien eines ursprünglich einzigen Forschungsknotens angelegt werden, wenn dieser von mehreren Forschungsknoten referenziert wird.
Die Load-Methode erhält als einzigen Parameter den Dateinamen (inkl. Pfad und Dateiendung) der zu ladenden Datei und gibt den deserialisierten Container zurück. Wichtig ist auch hier wieder eine korrekte Konfiguration. Sollten Felder im JSON fehlen, soll der Prozess mit einem Fehler beendet werden (statt uns stillschweigend einen ungültigen Datensatz zurückzuliefern). Auch hier wollen wir wieder, dass null-Werte inkludiert und Referenzen beachtet werden.
/// <summary>
/// A generic Json-Parser to save and load Data.
/// </summary>
/// <typeparam name="T">The Model-Type used to save and load Data.</typeparam>
internal class JsonParser<T>
{
/// <summary>
/// Saves the provided Container Data persistently as a JSON File.
/// </summary>
/// <param name="container">The DataContainer to save.</param>
/// <param name="fileName">The File to save the Data to.</param>
internal void SaveData(T container, string fileName)
{
//Save Data to File
try
{
using (StreamWriter file = File.CreateText(fileName))
{
JsonSerializer serializer = new JsonSerializer();
serializer.Formatting = Formatting.Indented;
serializer.NullValueHandling = NullValueHandling.Include;
serializer.PreserveReferencesHandling = PreserveReferencesHandling.All;
serializer.Serialize(file, container);
}
}
catch (Exception ex)
{
Debug.LogError($"An Exception occured while trying to save {fileName}: {ex.Message}{Environment.NewLine}{ex.StackTrace}");
}
}
/// <summary>
/// Loads the Data from a JSON File.
/// </summary>
/// <param name="fileName">The File to read the Data from.</param>
/// <returns>The DataContainer or null, if an Error occured.</returns>
internal T LoadData(string fileName)
{
//Read Json
try
{
using (StreamReader file = File.OpenText(fileName))
{
JsonSerializer serializer = new JsonSerializer();
serializer.MissingMemberHandling = MissingMemberHandling.Error;
serializer.NullValueHandling = NullValueHandling.Include;
serializer.PreserveReferencesHandling = PreserveReferencesHandling.All;
return (T)serializer.Deserialize(file, typeof(T));
}
}
catch (Exception ex)
{
Debug.LogError($"An Exception occured while trying to load {fileName}: {ex.Message}{Environment.NewLine}{ex.StackTrace}");
return default(T);
}
}
}
Nachdem die Infrastruktur zum Serialisieren steht, benötigen wir in unserem Editor zwei Methoden, um den JsonParser aufzurufen. Daher ergänzen wir diesen mit folgendem Code.
/// <summary>
/// Saves the ResearchNodes as JSON using the provided FileName.
/// </summary>
/// <param name="fileName">The FileName (including Path and Ending) to save the ResearchNodes at.</param>
private void SaveResearchNodes(string fileName)
{
JsonParser<List<ResearchNode>> jsonParser = new JsonParser<List<ResearchNode>>();
jsonParser.SaveData(researchNodes, fileName);
}
/// <summary>
/// Loads a Set of ResearchNodes from a serialized JSON-Container.
/// </summary>
/// <param name="fileName">The FileName (including Path and Ending) to load the ResearchNodes from.</param>
private void LoadResearchNodes(string fileName)
{
JsonParser<List<ResearchNode>> jsonParser = new JsonParser<List<ResearchNode>>();
researchNodes = jsonParser.LoadData(fileName);
}
Zuletzt fehlen noch zwei Buttons, über die wir die Methoden ansteuern können. Hierzu ergänzen wir die OnGUI-Methode um folgenden Code. Dieser muss vor dem Draw Lines Abschnitt eingefügt werden. Beide Buttons öffnen jeweils einen Dialog, über den man eine Datei auswählen, bzw. einen Dateinamen und Pfad eingeben kann. Sofern der Nutzer dies tut und den OK-Button im Dialog klickt, wird ein Dateiname mit Pfad und Dateiendung zurückgegeben, die wir direkt an die Save- und Load-Methoden weiterreichen können.
/// <summary>
/// This Method is called whenever the UI is updated.
/// </summary>
void OnGUI()
{
EditorGUILayout.BeginHorizontal();
{
//Define Load-Button
if (GUILayout.Button(new GUIContent("Load"), GUILayout.Width(150.0f)))
{
//Get the saved File
string file = EditorUtility.OpenFilePanel("Load Techtree", "", "json");
//Load Techtree from JSON
if (!string.IsNullOrEmpty(file))
{
LoadResearchNodes(file);
GenerateWindowsForResearchNodes();
}
}
//Define Save-Button
if (GUILayout.Button(new GUIContent("Save"), GUILayout.Width(150.0f)))
{
//Set the File to save
string file = EditorUtility.SaveFilePanel("Save Techtree", "", "Techtree", "json");
//Save Techtree as JSON
if (!string.IsNullOrEmpty(file))
{
SaveResearchNodes(file);
}
}
}
EditorGUILayout.EndHorizontal();
...
Nun sind beide Buttons in der Kopfzeile des Forschungsbaum-Editors zu sehen.
Ein Klick auf den Save-Button öffnet jetzt wie gewünscht einen SaveFile-Dialog, in dem der Dateiname und Ablageort gewählt werden können.
Mit dem Editieren und persistentem Speichern von Forschungsknoten verfügt der Forschungsbaum-Editor nun bereits über einige grundlegenden Funktionen. Um produktiv damit arbeiten zu können, bedarf es allerdings noch einiger Features. Im nächsten Tutorial wird erläutert, wie man neue Forschungsknoten hinzufügt und existierende Forschungsknoten löscht, sowie Verknüpfungen zwischen den Forschungsknoten editiert werden können.